中文文本分类-TextCNN模型原理超详解和代码实战
最近在学习中文文本分类各大模型,学理论的时候往往要东拼西凑(头大啊,就那种各种资料翻来翻去的不爽),突发灵感把找到的资料+自我理解汇总,方便后续学习! |
---|
文中有偏差的地方欢迎指出来!【邮箱2233054933@qq.com】 |
文中涉及的参考资料见文末 |
前导知识
语言模型
在 NLP 中,假设有一系列的样本数据(x,y),x -> y的映射关系为f,当把 x 看做一个句子里的一个词语,y 是这个词语的上下文词语,那么这里的 f,便是 NLP 中经常出现的语言模型(language model),这个模型的目的,就是判断 (x,y) 这个样本,是否符合自然语言的法则,更通俗点说就是:词语x和词语y放在一起,是不是人话。
统计模型:N-gram模型
语言模型有统计语言模型,也有神经语言模型。
什么是所谓的统计语言模型(Language Model)呢?简单来说**,统计语言模型就是用来计算句子概率的概率模型**。计算句子概率的概率模型很多,n-gram模型便是其中的一种。
假设一个长度为m的句子,包含这些词:[(w1,w2,w3,…,wm),那么**这个句子的概率(也就是这m个词共现的概率)**是:
一般来说,语言模型都是为了使得条件概率:P(wt|w1,w2,…,wt−1)最大化,不过考虑到近因效应,当前词只与距离它比较近的n个词更加相关(一般n不超过5),而非前面所有的词都有关。
因此上述公式可以近似为:
上述便是经典的n-gram模型的表示方式。
CNN
推荐这篇文章里的CNN讲解,通俗易懂
https://blog.csdn.net/Tink1995/article/details/104528624
TextCNN
模型原理
TextCNN是CNN在NLP方面的应用,主要用于文本分类。
上图是TextCNN模型结构(图片来自网络),由嵌入层embedding layer、卷积层CONV layer、池化层 Pooling layer、激励层ReLU layer、全连接层 FNN layer构成。
Embedding层
嵌入层就相当于CNN中的输入层,只不过在NLP中,每个单词/词语是以词向量的形式来表示地,所以这里的数据输入如上图所示,是一个个词向量堆叠形成的二维矩阵,也不需要对词向量进行预处理。图中最左边横向表示预训练词向量的维度,表示为k。图中k=5。则长度为n的句子就可以向量化表示为一个的矩阵。词向量的构造有几种方式:1、rand:随机初始化;2、static:使用预训练的词向量做初始化,然后固定Embedding层,训练网络;3、non-static:使用预训练的词向量做初始化,但Embedding层要跟随整个网络一起训练;4、multichannel。
卷积层
因为嵌入层是二维数据,也就是只相当于CNN中的一个feature map,然后我们设定的卷积核也就是二维数据,由于词向量只表征他对应的一个词,被切割之后是没有意义地,所以feature map中在同一行的数据是不能被滑动窗口给切割地,所以卷积核的维度,纵向可以自己设置,横向为词向量的维度,可以设置多个卷积核,采用不同长,同一宽。
每个卷积核的大小为filter_size*embedding_size。图中有3种卷积方式(3种region size),每种卷积核的纵向长度(滑动窗口大小)分别为2、3、4,横向长度自然是embedding词向量的维度。对于每个region size,都采用2个不同的卷积核进行卷积。即一共通过3*2=6个卷积核,得到6个不同的特征向量,再经过激活函数产生最终的特征向量。卷积操作有点类似于n-gram,提取了句中的2-gram、3-gram、4-gram信息,多个卷积是为了提取多种特征。
另外,有多少个卷积核就对应着生成多少个output feature map,output feature map 又可以作为下一层卷积层的feature map输入。
激励层
激活函数通俗理解就是把被激活的神经元的一些特征通过函数保留并且映射出来,将原本的线性函数转换成非线性函数,从而解决一些非线性问题。TextCNN一般都是采用ReLU激活函数。
什么是神经元嘞?
答: 一个卷积核也就是一个神经元 。
池化层
池化层夹在连续的卷积层中间,用于压缩数据和参数的量,减小过拟合。池化层主要使用两种方法:Max pooling 和 average pooling。这里采用的是最大池化Max pooling,即把上一步卷积后得到的每个特征向量output feature map的最大值提取出来,然后再拼接成一个新的特征向量。
输出softmax概率值
池化层后面接上全连接层,为防止过拟合,一般会添加dropout层和L2正则化方法。最终的输出层需要进行softmax概率归一化,然后使用softmax后的预测输出结果和真实标签计算交叉熵损失,最后整体使用Adam梯度法进行参数的更新、模型的优化。
程序代码
数据集来源与格式
代码原作者从THUCNews中抽取了20万条新闻标题,文本长度在20到30之间。一共10个类别,每类2万条。数据以字为单位输入模型。
类别:财经、房产、股票、教育、科技、社会、时政、体育、游戏、娱乐。
源代码作者数据量和数据集划分:
训练集 18万
验证集 1万
测试集 1万
实际数据量和数据集划分(缩减数据量到65000条。考虑到后续科研实际操作的数据不会有20万规模):
训练集 每类5000条×10类
验证集 每类500条×10类
测试集 每类1000条×10类
数据样例(一条数据占txt文本一行,文本和类别用‘\t’分开)
获取数据集的子集
方法一:失败,看不懂
https://blog.csdn.net/weixin_45968656/article/details/113747689
https://zhuanlan.zhihu.com/p/358383729
http://www.wtld.cn/a/346228.html
https://github.com/Jeremiah-210511/THUCNews-Classification-CNN
方法二:还是自己动手搞定吧,偷懒失败!
成功,名称带2的就是了,放进data文件夹后把名称后面的2去掉即可
pycharm打开,项目最底下的草稿文件夹里有个data_reduction.py就是了
使用前提
①定义预训练词向量。存到项目目录data文件夹下
可以自己训练,但预训练好的词向量收敛速度更快一点,自己预训练的词向量效果也不一定好。
预训练词向量下载地址(里面有很多类型的中文词向量,可以根据自己的需求下载)
Chinese Word Vectors 中文词向量:https://github.com/Embedding/Chinese-Word-Vectors
这篇文章使用的是 Sogou News 搜狗新闻 Word2vec词向量 基于Word + Character
②拿到四川大学机器智能实验室停用词库存到项目目录data文件夹下
缩减后的数据提取链接(含停用词 词表 提取好的词向量,直接可用):https://pan.baidu.com/s/1d-Jw_dSiq8ssSe5dlPO96g
提取码:2023
上关键代码!
utils.py
def build_vocab(file_path, tokenizer, max_size, min_freq):
# 先统计每个字出现的次数,然后按次数从大到小排序,构建vocab_list
# 然后根据vocab_list构建词表,出现次数多的字在词表中的位置靠前
# 最后在词表末尾加上UNK PAD
vocab_dic = {}
with open(file_path, 'r', encoding='UTF-8') as f:
for line in tqdm(f):
lin = line.strip()
if not lin:
continue
content = lin.split('\t')[0]
for word in tokenizer(content):
if word not in stopwords:
vocab_dic[word] = vocab_dic.get(word, 0) + 1
vocab_list = sorted([_ for _ in vocab_dic.items() if _[1] >= min_freq], key=lambda x: x[1], reverse=True)[:max_size]
vocab_dic = {word_count[0]: idx for idx, word_count in enumerate(vocab_list)}
# print(vocab_list)
# print(vocab_dic)
vocab_dic.update({UNK: len(vocab_dic), PAD: len(vocab_dic) + 1})
# print("更新vocab_dic")
# print(vocab_dic)
# exit()
return vocab_dic
if __name__ == "__main__":
'''提取预训练词向量'''
train_dir = "./THUCNews/data/train.txt"
vocab_dir = "./THUCNews/data/vocab.pkl"
pretrain_dir = "./THUCNews/data/sgns.sogou.char"
emb_dim = 300
filename_trimmed_dir = "./THUCNews/data/embedding_SougouNews"
# # 测试def build_vocab
# tokenizer = lambda x: [y for y in x] # 以字为单位构建词表
# build_vocab(train_dir, tokenizer=tokenizer, max_size=MAX_VOCAB_SIZE, min_freq=1)
# 用vocab词汇表生成预训练词向量
# vocab中每个字都转换成了唯一的id,但是还需要通过词embedding转换成词向量,可以用公开的SogouNews
# 搜狗新闻词向量数据sgns.sogou.char进行生成。
# 该数据集共有365076个中文的字符和词语,每个有300维的数据。根据这些已有的词向量,把vocab词汇表每个字生成300维的词向量。至此,预训练过程结束。
if os.path.exists(vocab_dir):
word_to_id = pkl.load(open(vocab_dir, 'rb'))
else:
# tokenizer = lambda x: x.split(' ') # 以词为单位构建词表(数据集中词之间以空格隔开)
tokenizer = lambda x: [y for y in x] # 以字为单位构建词表
word_to_id = build_vocab(train_dir, tokenizer=tokenizer, max_size=MAX_VOCAB_SIZE, min_freq=1)
pkl.dump(word_to_id, open(vocab_dir, 'wb'))
embeddings = np.random.rand(len(word_to_id), emb_dim)
f = open(pretrain_dir, "r", encoding='UTF-8')
for i, line in enumerate(f.readlines()):
# if i == 0: # 若第一行是标题,则跳过
# continue
lin = line.strip().split(" ")
if lin[0] in word_to_id:
idx = word_to_id[lin[0]]
emb = [float(x) for x in lin[1:301]]
embeddings[idx] = np.asarray(emb, dtype='float32')
f.close()
np.savez_compressed(filename_trimmed_dir, embeddings=embeddings)
def build_dataset(config, ues_word):
# 用vocab字典文件,把输入的训练集、验证集、测试集,每句话、每个字都转化成id格式。并和类别id,句子的长度数据放在一个元组内,后面会按批次生成迭代器。
if ues_word:
tokenizer = lambda x: x.split(' ') # 以空格隔开,word-level
else:
tokenizer = lambda x: [y for y in x] # char-level
if os.path.exists(config.vocab_path):
vocab = pkl.load(open(config.vocab_path, 'rb'))
else:
vocab = build_vocab(config.train_path, tokenizer=tokenizer, max_size=MAX_VOCAB_SIZE, min_freq=1)
pkl.dump(vocab, open(config.vocab_path, 'wb'))
print(f"Vocab size: {len(vocab)}")
def load_dataset(path, pad_size=32):
contents = []
with open(path, 'r', encoding='UTF-8') as f:
for line in tqdm(f): # tqdm专门用来记录迭代进度时间的
lin = line.strip()
if not lin:
continue
# content是txt文件每一行前面的文本,label是txt文件每一行后面的分类
content, label = lin.split('\t')
words_line = []
# 每个content填充满32个token
token = tokenizer(content)
seq_len = len(token)
if pad_size:
if len(token) < pad_size:
token.extend([PAD] * (pad_size - len(token)))
else:
token = token[:pad_size]
seq_len = pad_size
# word to id
for word in token: # 把字转换成id,不在字典里的就用unk的id
words_line.append(vocab.get(word, vocab.get(UNK)))
contents.append((words_line, int(label), seq_len))
# dataset返回的格式为([句子字id],类别,句子长度)
return contents # # [([...],3,22), ([...],1,18), ...]
train = load_dataset(config.train_path, config.pad_size)
dev = load_dataset(config.dev_path, config.pad_size)
test = load_dataset(config.test_path, config.pad_size)
return vocab, train, dev, test
def build_iterator(dataset, config):
# 分batch,创建Iterater
iter = DatasetIterater(dataset, config.batch_size, config.device)
return iter
TextCNN.py
class Config(object):
"""配置参数"""
def __init__(self, dataset, embedding):
self.model_name = 'TextCNN'
self.train_path = dataset + '/data/train.txt' # 训练集
self.dev_path = dataset + '/data/dev.txt' # 验证集
self.test_path = dataset + '/data/test.txt' # 测试集
self.class_list = [x.strip() for x in open(
dataset + '/data/class.txt', encoding='utf-8').readlines()] # 类别名单
self.vocab_path = dataset + '/data/vocab.pkl' # 词表
self.save_path = dataset + '/saved_dict/' + self.model_name + '.ckpt' # 模型训练结果
self.log_path = dataset + '/log/' + self.model_name
self.embedding_pretrained = torch.tensor(
np.load(dataset + '/data/' + embedding)["embeddings"].astype('float32'))\
if embedding != 'random' else None # 预训练词向量
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 设备
# original arg dropout = 0.5 require_improvement = 1000 num_epochs = 20 batch_size = 128
# pad_size = 32 learning_rate = 1e-3 filter_sizes = (2, 3, 4) num_filters = 256
self.dropout = 0.5 # 随机失活
self.require_improvement = 1000 # 若超过1000batch效果还没提升,则提前结束训练
self.num_classes = len(self.class_list) # 类别数
self.n_vocab = 0 # 词表大小,在运行时赋值
self.num_epochs = 20 # epoch数
self.batch_size = 128 # mini-batch大小
self.pad_size = 32 # 每句话处理成的长度(短填长切)
self.learning_rate = 1e-3 # 学习率
self.embed = self.embedding_pretrained.size(1)\
if self.embedding_pretrained is not None else 300 # 字向量维度
self.filter_sizes = (2, 3, 4) # 卷积核尺寸
self.num_filters = 160 # 卷积核数量(channels数)
'''Convolutional Neural Networks for Sentence Classification'''
"""定义TextCNN模型的不同层 输入预训练词向量 经过卷积 激励 池化 全连接层加入Dropout层 输出"""
class Model(nn.Module):
def __init__(self, config):
super(Model, self).__init__()
if config.embedding_pretrained is not None:
self.embedding = nn.Embedding.from_pretrained(config.embedding_pretrained, freeze=False)
else:
self.embedding = nn.Embedding(config.n_vocab, config.embed, padding_idx=config.n_vocab - 1)
# 有k种卷积核,每种卷积核大小为(k, config.embed) 每种num_filters个
self.convs = nn.ModuleList(
[nn.Conv2d(1, config.num_filters, (k, config.embed)) for k in config.filter_sizes])
# dropout一定程度上化解过拟合
self.dropout = nn.Dropout(config.dropout)
# 全线性连接层把最后一层卷积的结果竖着连到一起,分成num_classes输出
self.fc = nn.Linear(config.num_filters * len(config.filter_sizes), config.num_classes)
def conv_and_pool(self, x, conv):
# 一个batch进去,一次经过conv卷积层 relu激励层 pool池化层
x = F.relu(conv(x)).squeeze(3)
x = F.max_pool1d(x, x.size(2)).squeeze(2)
return x
def forward(self, x):
out = self.embedding(x[0])
out = out.unsqueeze(1)
out = torch.cat([self.conv_and_pool(out, conv) for conv in self.convs], 1)
out = self.dropout(out)
out = self.fc(out)
return out
run.py
这里只放了参数解读部分,因为TextCNN模型的train、test、evaluate函数都大同小异。
# 设置全局参数
# model:指定你想要训练的任何模型
#
# embedding:选择每个词embdedding的词向量是random随机初始化还是加载别人训练好的
#
# word参数默认为False,说明我们的中文文本是按单个字拆分训练的,True说明是按词语拆分
parser = argparse.ArgumentParser(description='Chinese Text Classification')
parser.add_argument('--model', type=str, required=True, help='choose a model: TextCNN, TextRNN, FastText, TextRCNN, TextRNN_Att, DPCNN, Transformer') # 相当于 model默认 = TextCNN
parser.add_argument('--embedding', default='pre_trained', type=str, help='random or pre_trained') # embedding = pre_trained
parser.add_argument('--word', default=False, type=bool, help='True for word, False for char') # word = False
args = parser.parse_args()
模型训练结果如下,跑了5个epoch自动end。不过不管怎样调参准确率都上不了90,ε=(´ο`*)))【调参小垃圾一枚】
No optimization for a long time, auto-stopping...
Test Loss: 0.39, Test Acc: 88.08%
Precision, Recall and F1-Score...
precision recall f1-score support
finance 0.9018 0.8720 0.8866 1000
realty 0.8939 0.9100 0.9019 1000
stocks 0.8318 0.7960 0.8135 1000
education 0.9671 0.9120 0.9388 1000
science 0.7826 0.8460 0.8131 1000
society 0.8756 0.9010 0.8881 1000
politics 0.8387 0.8790 0.8584 1000
sports 0.9078 0.9450 0.9260 1000
game 0.9265 0.8570 0.8904 1000
entertainment 0.8981 0.8900 0.8940 1000
accuracy 0.8808 10000
macro avg 0.8824 0.8808 0.8811 10000
weighted avg 0.8824 0.8808 0.8811 10000
Confusion Matrix...
[[872 17 61 0 14 8 15 9 0 4]
[ 15 910 18 0 13 16 11 5 3 9]
[ 48 32 796 1 60 2 49 5 5 2]
[ 1 3 3 912 15 20 13 13 2 18]
[ 8 9 40 3 846 20 27 6 28 13]
[ 4 20 5 13 8 901 33 3 3 10]
[ 12 13 21 5 20 34 879 7 1 8]
[ 1 4 3 0 7 8 8 945 3 21]
[ 1 3 6 2 83 8 7 17 857 16]
[ 5 7 4 7 15 12 6 31 23 890]]
参考
https://blog.csdn.net/v_JULY_v/article/details/102708459
https://zhuanlan.zhihu.com/p/526376136
https://blog.csdn.net/qq_43042024/article/details/125228593