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写规则进行的正负向标注。