TextCNN pytorch实现
TextCNN 是一种经典的DNN文本分类方法,自己实现一遍可以更好理解其原理,深入模型细节。本文并非关于TextCNN的完整介绍,假设读者比较熟悉CNN模型本身,仅对实现中比较费解的问题进行剖析。
项目地址:https://github.com/finisky/TextCNN
这里的实现基于: https://github.com/Shawn1993/cnn-text-classification-pytorch
主要改动:
- 简化了参数配置,希望呈现一个最简版本
- Fix一些由于pytorch版本升级接口变动所致语法错误
- Fix模型padding导致的runtime error
- 解耦模型model.py与training/test/prediction逻辑
- 定制tokenizer,默认中文jieba分词
- 使用torchtext的TabularDataset读取数据集:text abel
NLP中CNN的Channel与Filter
注:此处的Filter与Kernel的意义相同。
# Understanding Convolutional Neural Networks for NLP 对Channel和Filter的解释比较清楚。CNN是源于CV领域的一个概念,对于一张图来讲,RGB天然就是3个不同的Channel输入,而迁移到NLP中之后,文本其实只有一个Channel,但也可以通过使用不同的embedding(word2vec, glove, BERT等)或rephrase等方法将输入变成多个Channel。而Filter的目的是对于同一个输入用不同的kernel做卷积,从而提取出不同的Feature。用一个不是非常严谨的表述,Channel像是输入本身的一个天然存在的性质,而Filter是人为增加不同维度来提取Feature。
TextCNN模型
模型的细节参考下图(wildml盗图),无须赘述:
值得一提的是NLP的卷积操作和CV中有所不同,CV中的图像为二维输入,卷积会在横纵两个方向上进行,而NLP中的输入虽然看起来是二维的(token_num, embedding_dim),但卷积只在第一维方向进行,即每个滑动窗口都包括了整个embedding vector。道理很容易理解,每个token的语义由整个embedding vector表示,在局部的embedding vector上做卷积不太说得通。
nn.Conv2d详解
torch.nn.Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True, padding_mode='zeros')
参数含义
stride, padding,
dilation这些参数的意义,a picture is worth a thousand words
,
直接看动画即可: #
Convolution animations
kernel_size
, stride
, padding
,
dilation
参数类型可以是int也可以是tuple of two
ints,若为tuple,分别用在H维度与W维度。
输入与输出
输入:(N,C_{in},H,W)
输出:(N,C_{out},H,W)
就TextCNN而言,N为batchsize,C_{in} = 1, C_{out}为指定的kernel_num,H为一个句子中的token_num,W为embedding_dim。
例子
假设有个句子 token_num=4, embed_dim=5,使用一个3*embed_dim的kernel进行卷积,kernel_num = Co = 2。
既然是在H维度上卷积,每个卷积操作滑过整个embedding vector,所以kernel_size的第二个维度必定是embed_dim。此外,必须满足kernel_size[0] <= token_num,否则会有如下错误:
RuntimeError: Calculated padded input size per channel: (? x 5). Kernel size: (? x 5). Kernel size can't be greater than actual input size
特别地,在kernel_size[0]=3, token_num >= 1的情景下,需要在H维度上进行padding,且一定不能在W维度上padding,以保证卷积操作有效,每次都滑过整个embedding vector。因此,padding = (1, 0)。本文参考的原始实现没有对文本进行padding,如果数据集中存在过短的文本,训练时会出现runtime error。
import torch import torch.nn as nn torch.manual_seed(0) token_num = 4 embed_dim = 5 kernel_size = 3 Ci = 1 Co = 2 m = nn.Conv2d(Ci, Co, (kernel_size, embed_dim)) fixed_weight = (m.weight, m.bias) def conv_padding(x, padding, fixed_weight = None): conv = nn.Conv2d(Ci, Co, (kernel_size, embed_dim), padding = padding) if fixed_weight is not None: conv.weight = fixed_weight[0] conv.bias = fixed_weight[1] x = conv(x) return x raw_x = torch.randn(token_num, embed_dim) # (token_num, embed_dim) x = raw_x.unsqueeze(0).unsqueeze(0) # (1, 1, token_num, embed_dim) print ("x size: " + str(x.size())) print (x) print ("\nNo padding:") print (conv_padding(x, 0).size()) print (conv_padding(x, 0)) print ("\nPadding in the height axis:") print (conv_padding(x, (1, 0), fixed_weight).size()) print (conv_padding(x, (1, 0), fixed_weight)) print ("\nVerify padding vectors:") pad_x = torch.cat((torch.zeros(1, embed_dim), raw_x, torch.zeros(1, embed_dim))).unsqueeze(0).unsqueeze(0) print (pad_x) print (conv_padding(pad_x, 0, fixed_weight))
执行结果如下:
x size: torch.Size([1, 1, 4, 5]) tensor([[[[-0.6136, 0.0316, -0.4927, 0.2484, -0.2303], [-0.3918, 0.5433, -0.3952, 0.2055, -0.4503], [-0.5731, -0.5554, -1.5312, -1.2341, 1.8197], [-0.5515, -1.3253, 0.1886, -0.0691, -0.4949]]]]) No padding: torch.Size([1, 2, 2, 1]) tensor([[[[ 0.1010], [ 0.3119]], [[ 0.3740], [-0.1368]]]], grad_fn=<MkldnnConvolutionBackward>) Padding in the height axis: torch.Size([1, 2, 4, 1]) tensor([[[[-0.1523], [ 0.3070], [-0.0044], [ 0.1774]], [[ 0.4631], [ 0.5886], [-0.5039], [-0.4035]]]], grad_fn=<MkldnnConvolutionBackward>) Verify padding vectors: tensor([[[[ 0.0000, 0.0000, 0.0000, 0.0000, 0.0000], [-0.6136, 0.0316, -0.4927, 0.2484, -0.2303], [-0.3918, 0.5433, -0.3952, 0.2055, -0.4503], [-0.5731, -0.5554, -1.5312, -1.2341, 1.8197], [-0.5515, -1.3253, 0.1886, -0.0691, -0.4949], [ 0.0000, 0.0000, 0.0000, 0.0000, 0.0000]]]]) tensor([[[[-0.1523], [ 0.3070], [-0.0044], [ 0.1774]], [[ 0.4631], [ 0.5886], [-0.5039], [-0.4035]]]], grad_fn=<MkldnnConvolutionBackward>)
易得,原始的句子输入为 45,如果是未padding的输入经过35卷积核后结果为21,在H维度padding之后卷积的结果为41。
从nn.Conv2d中很难拿到padding之后的输入vector,为了验证padding与我们预期的结果一致,将nn.Conv2d中的weight和bias都固定下来,分别计算no padding input + model padding = (1,0) 与padding input + model padding = 0的结果,二者输出一致,证明padding正确。
代码实现
https://github.com/finisky/TextCNN
model.py
https://github.com/finisky/TextCNN/blob/master/model.py
核心模型代码,比较简洁,与args解耦:
import torch import torch.nn as nn import torch.nn.functional as F class TextCnn(nn.Module): def __init__(self, embed_num, embed_dim, class_num, kernel_num, kernel_sizes, dropout = 0.5): super(TextCnn, self).__init__() Ci = 1 Co = kernel_num self.embed = nn.Embedding(embed_num, embed_dim) self.convs1 = nn.ModuleList([nn.Conv2d(Ci, Co, (f, embed_dim), padding = (2, 0)) for f in kernel_sizes]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(Co * len(kernel_sizes), class_num) def forward(self, x): x = self.embed(x) # (N, token_num, embed_dim) x = x.unsqueeze(1) # (N, Ci, token_num, embed_dim) x = [F.relu(conv(x)).squeeze(3) for conv in self.convs1] # [(N, Co, token_num) * len(kernel_sizes)] x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] # [(N, Co) * len(kernel_sizes)] x = torch.cat(x, 1) # (N, Co * len(kernel_sizes)) x = self.dropout(x) # (N, Co * len(kernel_sizes)) logit = self.fc(x) # (N, class_num) return logit
因为最大kernel_size是5,所以padding=(2,0),保证卷积操作有效。
还有个比较费解的地方,kernel_num和len(kernel_sizes)有什么区别?kernel_sizes是一组不同size的kernel,而kernel_num指的是有多少组这样的kernel。所以,实际上整个网络中kernel的总数是kernel_num * len(kernel_sizes)。这点从TextCNN模型图中也可看出,共有kernel_num=2组kernels,每个组中有3/4/5三个不同size的kernel,共6个kernel。
operation.py
https://github.com/finisky/TextCNN/blob/master/operation.py
完成模型的外围操作,包括train(), test(), predict()和save()。 不贴完整代码了,只有一处需要解释:
for batch in train_iter: feature, target = batch.text, batch.label feature.t_(), target.sub_(1) # batch first, index align
target为什么需要减1?原因在于label_field.vocab里包括一个<unk>
:
{'<unk>': 0, '1': 1, '0': 2}
main.py
https://github.com/finisky/TextCNN/blob/master/main.py
程序入口,parse参数,读取数据,分词。 也不贴完整代码了,只贴读取数据和建词表(data format: text abel):
text_field = data.Field(lower=True, tokenize = tokenize) label_field = data.Field(sequential=False) fields = [('text', text_field), ('label', label_field)] train_dataset, test_dataset = data.TabularDataset.splits( path = './data/', format = 'tsv', skip_header = False, train = 'train.tsv', test = 'test.tsv', fields = fields ) text_field.build_vocab(train_dataset, test_dataset, min_freq = 5, max_size = 50000) label_field.build_vocab(train_dataset, test_dataset) train_iter, test_iter = data.Iterator.splits((train_dataset, test_dataset), batch_sizes = (args.batch_size, args.batch_size), sort_key = lambda x: len(x.text))
替换原始实现的mydataset.py,同时建词表时设置了min_freq和max_size。
运行方法
随机选了23000条 weibo_senti_100k 数据,其中train/test分别有20000和3000条。
Train
python main.py -train
Test
python main.py -test -snapshot snapshot/best_steps_400.pt
运行结果:
Evaluation - loss: 0.061201 acc: 98.053% (2518/2568)
Predict
python main.py -predict -snapshot snapshot/best_steps_400.pt
运行结果:
>>内牛满面~[泪] 0 | 内牛满面~[泪] >>啧啧啧,好幸福好幸福 1 | 啧啧啧,好幸福好幸福
虽然测试的准确率很高,但实际测试的效果一般,原因在于weibo_senti_100k的文本分类任务比较简单,这个数据集比较脏,大概率是按文本中的emoji写规则进行的正负向标注。
Preview: