本篇博客主要介绍基于DPCNN的文本分类算法的原理及实现细节。
目录
1. 分类原理
ACL2017年中,腾讯AI-lab提出了Deep Pyramid Convolutional Neural Networks for Text Categorization(DPCNN)。论文中提出了一种基于word-level级别的网络-DPCNN,由于之前介绍的TextCNN 不能通过卷积获得文本的长距离依赖关系,而论文中DPCNN通过不断加深网络,可以抽取长距离的文本依赖关系。实验证明在不增加太多计算成本的情况下,增加网络深度就可以获得最佳的准确率。
- 等长卷积
首先交代一下卷积的的一个基本概念。一般常用的卷积有以下三类:
假设输入的序列长度为n,卷积核大小为m,步长(stride)为s,输入序列两端各填补p个零(zero padding),那么该卷积层的输出序列为(n-m+2p)/s+1。
1)窄卷积:步长s=1,两端不补零,即p=0,卷积后输出长度为n-m+1。
2)宽卷积:步长s=1,两端补零p=m-1,卷积后输出长度 n+m-1。
3)等长卷积: 步长s=1,两端补零p=(m-1)/2,卷积后输出长度为n。(不改变序列长度)
- 池化
那么DPCNN是如何捕捉长距离依赖的呢?——Downsampling with the number of feature maps fixed。
作者选择了适当的两层等长卷积来提高词位embedding的表示的丰富性。然后接下来就开始 Downsampling (池化)。再每一个卷积块(两层的等长卷积)后,使用一个size=3和stride=2进行maxpooling进行池化。序列的长度就被压缩成了原来的一半。其能够感知到的文本片段就比之前长了一倍。
DPCNN中重复使用多个这种模块,每个模块包含两层等长卷积(不改变序列长度)和一个池化下采样(序列长度减半)。
例如之前是只能感知3个词位长度的信息,经过1/2池化层后就能感知6个词位长度的信息啦,这时把1/2池化层和size=3的卷积层组合起来如图所示。
- 固定feature map(filters/卷积核)的数量
为什么要固定feature maps的数量呢?许多模型每当执行池化操作时,增加feature maps的数量(即序列减半,输出通道数加倍),导致总计算复杂度是深度的函数。与此相反,作者对feature map的数量进行了修正,他们实验发现增加feature map的数量只会大大增加计算时间,而没有提高精度。
固定了feature map的数量,每当使用一个size=3和stride=2进行max pooling进行池化时,每个卷积层的计算时间减半(数据大小减半/序列长度减半),从而形成一个金字塔。
剩下的我们就只需要重复的进行等长卷积+等长卷积+使用一个size=3和stride=2进行maxpooling进行池化就可以啦,DPCNN就可以捕捉文本的长距离依赖啦!还有一个问题,网络加深时,需要解决梯度消失问题。
- shortcut connections with pre-activation
(1) 初始化CNN的时,往往各层权重都初始化为很小的值,这导致了最开始的网络中,后续几乎每层的输入都是接近0,这时的网络输出没有意义;
(2) 小权重阻碍了梯度的传播,使得网络的初始训练阶段往往要迭代好久才能启动;
(3) 就算网络启动完成,由于深度网络中仿射矩阵(每两层间的连接边)近似连乘,训练过程中网络也非常容易发生梯度爆炸或弥散问题。
当然,上述这几点问题本质就是梯度弥散问题。那么如何解决深度CNN网络的梯度弥散问题呢?当然是膜一下何恺明大神,然后把ResNet的精华拿来用啦! ResNet中提出的shortcut-connection/ skip-connection/ residual-connection(残差连接)就是一种非常简单、合理、有效的解决方案。
类似地,为了使深度网络的训练成为可能,作者为了恒等映射,所以使用加法进行shortcut connections,即z+f(z),其中 f 用的是两层的等长卷积(z和f(z)的形状相同,可以直接相加)。这样就可以极大的缓解了梯度消失问题。
另外,作者也使用了 pre-activation,这个最初在何凯明的“Identity Mappings in Deep Residual Networks上提及,有兴趣的大家可以看看这个的原理。直观上,这种“线性”简化了深度网络的训练,类似于LSTM中constant error carousels的作用。而且实验证明 pre-activation优于post-activation(即把激活函数放在卷积操作前面,卷积层的顺序为 BatchNorm-ReLU-Conv)。
- Region Embedding
同时DPCNN的底层保持了跟TextCNN一样的结构,这里作者将TextCNN的包含多尺寸卷积滤波器的卷积层的卷积结果称之为Region embedding,意思就是对一个文本区域/片段(比如3gram,卷积核大小为3)进行一组卷积操作后生成的embedding。
另外,作者为了进一步提高性能,还使用了tv-embedding (two-views embedding)进一步提高DPCNN的accuracy。
2. 实现细节
class ResnetBlock(nn.Module):
def __init__(self, channel_size):
super(ResnetBlock, self).__init__()
self.channel_size = channel_size
#将序列长度减半
self.maxpool = nn.Sequential(
nn.ConstantPad1d(padding=(0, 1), value=0), #在每个通道上(一维) 一边填充0个0(不填充) 另一边填1个0
nn.MaxPool1d(kernel_size=3, stride=2) #序列长度减半 height = (height-kernel_size+padding+stride)//stride=height // 2
)
self.conv = nn.Sequential( #等长卷积 不改变height
nn.BatchNorm1d(num_features=self.channel_size),
nn.ReLU(),
nn.Conv1d(self.channel_size, self.channel_size, kernel_size=3, padding=1),
nn.BatchNorm1d(num_features=self.channel_size),
nn.ReLU(),
nn.Conv1d(self.channel_size, self.channel_size, kernel_size=3, padding=1),
)
def forward(self, x): #(batch_size,channel_size,seq_len)
x_shortcut = self.maxpool(x) # (batch_size,channel_size,seq_len//2)
x = self.conv(x_shortcut)#(batch_size,channel_size,seq_len//2)
x = x + x_shortcut#(batch_size,channel_size,seq_len//2) shortcut 残差连结
return x
class DPCNN(BasicModule):#继承自BasicModule 其中封装了保存加载模型的接口,BasicModule继承自nn.Module
def __init__(self,vocab_size,opt): #opt是config类的实例 里面包括所有模型超参数的配置
super(DPCNN, self).__init__()
# 嵌入层
self.embedding = nn.Embedding(vocab_size, opt.embed_size)#词嵌入矩阵 每一行代表词典中一个词对应的词向量;
# 词嵌入矩阵可以随机初始化连同分类任务一起训练,也可以用预训练词向量初始化(冻结或微调)
# region embedding
self.region_embedding = nn.Sequential(
nn.Conv1d(opt.embed_size, opt.channel_size, kernel_size=3, padding=1), #same卷积 不改变height/序列长度
nn.BatchNorm1d(num_features=opt.channel_size),
nn.ReLU(),
nn.Dropout(opt.drop_prop_dpcnn)
)
#卷积块 same卷积 不改变height
self.conv_block = nn.Sequential(
nn.BatchNorm1d(num_features=opt.channel_size),
nn.ReLU(),
nn.Conv1d(opt.channel_size, opt.channel_size, kernel_size=3, padding=1),
nn.BatchNorm1d(num_features=opt.channel_size),
nn.ReLU(),
nn.Conv1d(opt.channel_size, opt.channel_size, kernel_size=3, padding=1),
)
self.seq_len = opt.max_len #序列最大长度
resnet_block_list = [] #存储多个残差块
while (self.seq_len > 2): #每经过一个残差块 序列长度减半 只要长度>2 就不停地加残差块
resnet_block_list.append(ResnetBlock(opt.channel_size))
self.seq_len = self.seq_len // 2
#将残差块 构成残差层 作为一个子模块
self.resnet_layer = nn.Sequential(*resnet_block_list)
#输出层 分类
self.linear_out = nn.Linear(self.seq_len * opt.channel_size, opt.classes)
def forward(self, inputs):
embeddings = self.embedding(inputs) #(batch_size,max_len,embed_size)
x = embeddings.permute(0, 2, 1) #(batch_size,embed_size,max_len) 交换维度 作为1维卷积的输入 embed_size 作为通道维
x = self.region_embedding(x) #(batch_size,channel_size,max_len)
x = self.conv_block(x) #(batch_size,channel_size,max_len)
x = self.resnet_layer(x) #经过多个残差块 每次长度减半 (batch_size,channel_size,self.seq_len)
x = x.permute(0, 2, 1) #(batch_size,self.seq_len,channel_size)
x = x.contiguous().view(x.size(0), -1) #(batch_size,self.seq_len*channel_size) 拉伸为向量
out = self.linear_out(x) #(batch_size,classes)
return out