基于 Tensorflow 的 TextCNN 在搜狗新闻数据的文本分类实践

这篇文章记录了从零开始用 tensorflow 构建卷积神经网络模型,并对搜狗的新闻数据做的文本分类实践。众所周知,tensorflow 是一个开源的机器学习框架,它的出现大大降低了机器学习的门槛,即使你没有太多的数学知识,它也可以允许你用“搭积木”的方式快速实现一个神经网络,即使没有调节太多的参数,模型的表现一般还不错。目前,tensorflow 的安装已经变得非常简单,一个简单的pip install tensorflow即可,然后import tensorflow as tf就能愉快玩耍了。

1. 背景

卷积神经网络,即CNN,它的核心思想是捕捉数据的局部特征(感兴趣的同学可以阅读我先前写的一篇关于CNN的笔记:机器学习算法系列(13)理解卷积神经网络 | Thinking Realm),不仅仅在图像领域大放异彩,CNN在文本分类领域也有很强的表现。在Yoon Kim的这篇论文中,比较清楚地解释了CNN用于文本分类的原理,关键在于如何将文本向量化,如下图,即把每个词都表示为一个 1×k的向量,对长度为N的文本则表示为N×K的矩阵,经过这一步处理,那么我们就可以把图像上的分类经验应用到文本上来了。

机器学习算法系列(13)理解卷积神经网络 \| Thinking Realm

现在有预训练好的中文词向量,但这不是本文的重点,因为这里不需要用到预训练好的词向量,本文的文本分类是基于字符级的CNN实现,也就是并没有对文本数据做分词处理,而是从原始文本中建立词汇表,然后把文本中的每个字符都对应编码。比如,“我爱北京天安门。”,我们就会把这段文本全部打散成为“我”、“爱”、“北”、“京”、“天”、“安”、“门”、“。”,甚至标点符号、特殊字符都会有对应的编码,一开始还有怀疑,不过从模型的表现来看,真香。

本文用到的数据集来自搜狗实验室(Sogou Labs)提供的新闻数据,涵盖了国内,国际,体育,社会,娱乐等18个频道的新闻数据,不过数据集的质量不是特别高,存在大量的分类不清晰、文不对题数据。限于单机的性能,又没有 GPU,所以我只下载了精简版的一个月数据,大约 347M。用 sublime 打开原始数据是乱码的,解决的方案见:如何解决Sublime Text 3不能正确显示中文的问题 - 冷编程 - SegmentFault 思否,然后,打开长这样:

xml的格式,gbk编码,每一个<doc></doc>之间是一篇单独的新闻,包含URL、文档编号、标题和正文,其中新闻的分类类别在URL的子域名中,如sports代表体育,house代表房产等等,所以本文只需要拿到URL和content之间的内容就行。

2. 代码解析

OK,背景介绍差不多到这里就结束了,下面是代码实现的解读:

2.1 数据清洗

下载下来的原始数据分为129个TXT文件,每个文件中包含有不同类别的新闻数据,我要做的是遍历每个文件,然后把相同类别的新闻提取出来并写入新的文件中。读取中文txt文档乱码依旧是个头疼的问题,话说我至今仍然弄不是很清楚Python的编码模式。读取完成后,提取出URL和content,我们还需要用正则表达式把子域名拿出来,最后得到15个按类别分好的文件,最终留下了11个类别。

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/usr/bin/python
# coding: utf-8

import os
import re


def _read_file(txt_file):
"""读取txt文件"""
return open(txt_file, 'rb').read().decode("gbk", 'ignore')


def extract_class_content(doc):
"""提取分类和内容"""
url = doc.split('<url>')[1].split('</url>')[0]
content = doc.split('<content>')[1].split('</content>')[0]
category = re.findall(r"http://(.*?).sohu.com/", url)
return category[0], content


def file_writer(category, content):
dir_name = '/home/libin/data/'
path = os.path.join(dir_name, category)
f = open(path, 'a', encoding='utf-8')
f.write(category + '\t' + content + '\n')
f.close()


def category_data(txt_file):
"""将每个文件中不同类别的新闻分别存储"""
f = _read_file(txt_file)
docs_xmls = f.split('<doc>\n')
for doc in docs_xmls:
if doc:
category, content = extract_class_content(doc)
file_writer(category, content)


if __name__ == '__main__':
for file in os.listdir('/home/libin/data/SogouCS.reduced/'):
file_path = os.path.join('/home/libin/data/SogouCS.reduced', file)
category_data(file_path)

然后,看看各个类别下数据量的分布,发现体育、商业、新闻的数量较多,文化类的比较少,数据分布不太平衡,但这并不影响,因为我们并不会用到全部的数据,而是从每个类别中抽取一部分来训练模型。

1
2
3
4
5
6
7
8
9
10
11
 7241 auto
61843 business
3291 cul
5482 health
12353 it
10673 learning
2930 mil.news
82740 news
85984 sports
8957 travel
33091 yule

2.2 准备数据

数据清洗完成后,下一步就是为模型准备训练集、验证集和测试集数据, 训练集和测试集按照4:1的比例分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def save_file(dir_name):
f_train = open('../data/news_train.txt', 'w', encoding='utf-8')
f_test = open('../data/news_test.txt', 'w', encoding='utf-8')
f_val = open('../data/news_val.txt', 'w', encoding='utf-8')
for category in os.listdir(dir_name):
cat_file = os.path.join(dir_name, category)
fp = _read_file(cat_file)
count = 0
for line in fp:
category, content = _unpack_line(line)
if category and content:
if count < 400:
f_train.write(category + '\t' + content + '\n')
elif count < 500:
f_test.write(category + '\t' + content + '\n')
elif count < 550:
f_val.write(category + '\t' + content + '\n')
else:
break
count += 1

print('Finished', category)

f_train.close()
f_test.close()
f_val.close()

考虑到单机的计算能力,一开始我没有抽取太多的数据,仅仅从每个类别抽取400条作为训练集,100条作为测试集,50条作为验证集。第一组数据测试训练模型时,物理内存占用率并不高,CPU才是最占用计算资源的。下图中显示CPU已经在超负荷工作,而内存无动于衷,在这个基础上开始试着增大数据量。

内存和CPU占用

构建词汇表:vocab

上述的准备工作做完了之后,数据的准备并没有结束,因为我们还没有为字符->向量做好铺垫,通常的做法是加入已经训练好的词向量(比如,这个链接归纳总结的预训练好的词向量就比较全)。在本文呢我没有用它们,而是筛选出的训练集语料中出现频次较高的5000个字符作为词汇表,我比较好奇的是我并没有对原始语料做任何的清洗、去噪,却丝毫不影响分类器的表现。添加一个 <PAD>来将所有文本pad为同一长度

1
2
3
4
5
6
7
8
9
10
11
12
13
def build_vocab(train_path, vocab_path, vocab_size=5000):
"""构建词汇表"""
data_train, _ = read_file(train_path)

all_data = []
for content in data_train:
all_data.extend(content)

counter = Counter(all_data)
counter_pairs = counter.most_common(vocab_size-1)
words, _ = list(zip(*counter_pairs))
words = ['<PAD>'] + list(words)
open_file(vocab_path, mode='w').write('\n'.join(words) + '\n')

提取出来的词汇表长这样,停用词、标点符号居多。

image

词汇表建立好了,txt文件并不适合查询,所以这里用字符在文件的顺序作为其标识的id,存储到字典word_to_id中,这样以来就方便查找了。

1
2
3
4
5
def read_vocab(vocab_path):
with open(vocab_path) as f:
words = [_.strip() for _ in f.readlines()]
word_to_id = dict(zip(words, range(len(words))))
return words, word_to_id

类别编码(因变量)

1
2
3
4
def read_category():
categories = ['mil.news', 'cul', 'health', 'travel', 'auto', 'learning', 'it', 'yule', 'sports', 'business', 'news']
cat_to_id = dict(zip(categories, range(len(categories))))
return categories, cat_to_id

处理数据

做完构建词汇表、类别转换为one-hot编码的准备工作,终于要进入正题了,数据进入模型训练、验证、测试前的准备工作还没有做。下面,process_file()函数首先读取数据文件,将正文和标签分别对应存储在contentslabels两个列表中,然后再处理contents中的每一段文本,把文本中每一个字符在词汇表中找到其对应的id,完成文本数值化操作。类别转换为one-hot表示:y_pad = kr.utils.to_categorical(label_id, num_classes=len(cat_to_id))

1
2
3
4
5
6
7
8
9
10
11
12
13
def process_file(file_name, word_to_id, cat_to_id, max_length=600):
contents, labels = read_file(file_name)

data_id, label_id = [], []
for i in range(len(contents)):
data_id.append([word_to_id[x] for x in contents[i] if x in word_to_id])
label_id.append(cat_to_id[labels[i]])

# 使用keras提供的pad_sequences来将文本pad为固定长度
x_pad = kr.preprocessing.sequence.pad_sequences(data_id, max_length)
y_pad = kr.utils.to_categorical(label_id, num_classes=len(cat_to_id)) # 将标签转换为one-hot表示

return x_pad, y_pad

2.3 CNN模型设置

CNN参数设置

区别于传统的机器学习,现有任务下,一般的深度学习即使没有经过参数调节也可以达到不错的效果,可见其强大之处。由于上述的原因,往往深度学习也被诟病为“黑箱操作”,因为它比较难以理解,比如对于不太了解深度学习的人,从字符->向量转化过程的理解就比较困难,字符怎么就可以转化成为可以计算的数值呢?就算字符的向量化过程完成了,当有新的数据进入训练模型,它们又是如何从已有的词汇表中匹配到对应的向量?这些都是需要考虑的问题……

解释一下CNN常见的配置参数:

  • seq_length是输入矩阵的宽度,由输入数据的长度决定,考虑到新闻长度会很长,所以我把矩阵的宽度设置为1000
  • embedding_dim,词向量的宽度,即由现有语料训练得到字向量的宽度,默认设置为64
  • num_classes则根据你实际的类别来设定,设置为11
  • dropout_keep_prob是dropout的比例,一般设置为0.5
  • num_epochs全部数据通过神经网络的次数,决定经过多少轮后停止训练,我在模型中设置为10,实际中有可能没有到10轮就停止了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class TCNNConfig(object):
"""CNN配置参数"""

embedding_dim = 64 # 词向量宽度
seq_length = 1000 # 输入矩阵的宽度
num_classes = 11 # 类别数
num_filters = 256 # 卷积核数目
kernel_size = 5 # 卷积核尺寸,即卷积核覆盖的词汇数量
vocab_size = 6000 # 词汇表大小

hidden_dim = 128 # 全连接层神经元

dropout_keep_prob = 0.5 # dropout保留比例
learning_rate = 1e-3 # 学习率

batch_size = 64 # 每批训练大小
num_epochs = 10 # 总迭代轮次

print_per_batch = 100 # 每多少轮输出一次结果
save_per_batch = 10 # 每多少轮存入tensorboard

文本分类模型

tf.placeholder()是创建占位符,给输入数据腾出空间,第二个参数是占位符的形状,设置为None是为了使模型可以接受任意数量的数据。self.input_x = tf.placeholder(tf.int32, [None, self.config.seq_length], name='input_x')代表创建大小为[None, seq_length]的空间,其中这个空间中的每一行代表了一条输入数据,在CNN模型中我们将其设置为1000,表示只取文本的前1000个字符,之后会用字符在词汇表中的id来对self.input_x填充。

网络的第一层是嵌入层,将词汇映射到低维向量,设置为64,嵌入操作tf.nn.embedding_lookup(embedding, self.input_x)完成后,输出结果是3D张量,形如[None, sequence_length, embedding_dim],对应了下图:

机器学习算法系列(13)理解卷积神经网络 \| Thinking Realm

嵌入操作完成后,紧接着便是卷积和池化层,卷积核大小设置为5,表示卷积核每次扫过5个字符,一共有256个卷积核,然后对卷积核生成的feature map做最大池化,池化之后便是第一个全连接层,计算之后dropout掉一些元素,接着是修正线性单元激活函数和softmax层,最后返回softmax层最大值的索引,即预测类别的id。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class TextCNN(object):
"""文本分类,CNN模型"""

def __init__(self, config):
self.config = config

# 三个待输入的数据,腾出占位符
# input_x 为 n * seq_length 的矩阵,n 大小不固定
# input_y 同
self.input_x = tf.placeholder(tf.int32, [None, self.config.seq_length], name='input_x')
self.input_y = tf.placeholder(tf.float32, [None, self.config.num_classes], name='input_y')
self.keep_prob = tf.placeholder(tf.float32, name='keep_prob')

self.cnn()

def cnn(self):
"""CNN模型"""
# 词向量映射
with tf.device('/cpu:0'): # 强制使用CPU
embedding = tf.get_variable('embedding', [self.config.vocab_size, self.config.embedding_dim])
embedding_inputs = tf.nn.embedding_lookup(embedding, self.input_x)

with tf.name_scope("cnn"):
# CNN layer
conv = tf.layers.conv1d(embedding_inputs, self.config.num_filters, self.config.kernel_size, name='conv')
# global max pooling layer
gmp = tf.reduce_max(conv, reduction_indices=[1], name='gmp')

with tf.name_scope("score"):
# 全连接层,后面接dropout以及relu激活
# 激活函数后得到第二个全连接层
fc = tf.layers.dense(gmp, self.config.hidden_dim, name='fc1')
fc = tf.contrib.layers.dropout(fc, self.keep_prob)
fc = tf.nn.relu(fc) # 修正线性单元激活函数,大于零才被激活

# 分类器
self.logits = tf.layers.dense(fc, self.config.num_classes, name='fc2')
self.y_pred_cls = tf.argmax(tf.nn.softmax(self.logits), 1) # 预测类别,返回最大值的索引

with tf.name_scope("optimize"):
# 损失函数,交叉熵
cross_entropy = tf.nn.softmax_cross_entropy_with_logits_v2(logits=self.logits, labels=self.input_y)
self.loss = tf.reduce_mean(cross_entropy)
# 优化器
self.optim = tf.train.AdamOptimizer(learning_rate=self.config.learning_rate).minimize(self.loss)

with tf.name_scope("accuracy"):
# 准确率
correct_pred = tf.equal(tf.argmax(self.input_y, 1), self.y_pred_cls)
self.acc = tf.reduce_mean(tf.cast(correct_pred, tf.float32))

2.4 训练和测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def train():
print("Configuring TensorBoard and Saver...")
# 配置 Tensorboard,重新训练时,请将tensorboard文件夹删除,不然图会覆盖
tensorboard_dir = 'tensorboard/textcnn'
if not os.path.exists(tensorboard_dir):
os.makedirs(tensorboard_dir)

tf.summary.scalar("loss", model.loss)
tf.summary.scalar("accuracy", model.acc)
merged_summary = tf.summary.merge_all()
writer = tf.summary.FileWriter(tensorboard_dir)

# 配置 Saver
saver = tf.train.Saver()
if not os.path.exists(save_dir):
os.makedirs(save_dir)

print("Loading training and validation data...")
# 载入训练集与验证集
start_time = time.time()
x_train, y_train = process_file(train_dir, word_to_id, cat_to_id, config.seq_length)
x_val, y_val = process_file(val_dir, word_to_id, cat_to_id, config.seq_length)
time_dif = get_time_dif(start_time)
print("Time usage:", time_dif)

# 创建session
session = tf.Session()
session.run(tf.global_variables_initializer())
writer.add_graph(session.graph)

print('Training and evaluating...')
start_time = time.time()
total_batch = 0 # 总批次
best_acc_val = 0.0 # 最佳验证集准确率
last_improved = 0 # 记录上一次提升批次
require_improvement = 1000 # 如果超过1000轮未提升,提前结束训练

flag = False
for epoch in range(config.num_epochs):
print('Epoch:', epoch + 1)
batch_train = batch_iter(x_train, y_train, config.batch_size)
for x_batch, y_batch in batch_train:
feed_dict = feed_data(x_batch, y_batch, config.dropout_keep_prob)

if total_batch % config.save_per_batch == 0:
# 每多少轮次将训练结果写入tensorboard scalar
s = session.run(merged_summary, feed_dict=feed_dict)
writer.add_summary(s, total_batch)

if total_batch % config.print_per_batch == 0:
# 每多少轮次输出在训练集和验证集上的性能
feed_dict[model.keep_prob] = 1.0
loss_train, acc_train = session.run([model.loss, model.acc], feed_dict=feed_dict)
loss_val, acc_val = evaluate(session, x_val, y_val) # todo

if acc_val > best_acc_val:
# 保存最好结果
best_acc_val = acc_val
last_improved = total_batch
saver.save(sess=session, save_path=save_path)
improved_str = '*'
else:
improved_str = ''

time_dif = get_time_dif(start_time)
msg = 'Iter: {0:>6}, Train Loss: {1:>6.2}, Train Acc: {2:>7.2%},' \
+ ' Val Loss: {3:>6.2}, Val Acc: {4:>7.2%}, Time: {5} {6}'
print(msg.format(total_batch, loss_train, acc_train, loss_val, acc_val, time_dif, improved_str))

session.run(model.optim, feed_dict=feed_dict) # 运行优化
total_batch += 1

if total_batch - last_improved > require_improvement:
# 验证集正确率长期不提升,提前结束训练
print("No optimization for a long time, auto-stopping...")
flag = True
break # 跳出循环
if flag: # 同上
break


def test():
print("Loading test data...")
start_time = time.time()
x_test, y_test = process_file(test_dir, word_to_id, cat_to_id, config.seq_length)

session = tf.Session()
session.run(tf.global_variables_initializer())
saver = tf.train.Saver()
saver.restore(sess=session, save_path=save_path) # 读取保存的模型

print('Testing...')
loss_test, acc_test = evaluate(session, x_test, y_test)
msg = 'Test Loss: {0:>6.2}, Test Acc: {1:>7.2%}'
print(msg.format(loss_test, acc_test))

batch_size = 128
data_len = len(x_test)
num_batch = int((data_len - 1) / batch_size) + 1

y_test_cls = np.argmax(y_test, 1)
y_pred_cls = np.zeros(shape=len(x_test), dtype=np.int32) # 保存预测结果
for i in range(num_batch): # 逐批次处理
start_id = i * batch_size
end_id = min((i + 1) * batch_size, data_len)
feed_dict = {
model.input_x: x_test[start_id:end_id],
model.keep_prob: 1.0
}
y_pred_cls[start_id:end_id] = session.run(model.y_pred_cls, feed_dict=feed_dict)

# 评估
print("Precision, Recall and F1-Score...")
print(metrics.classification_report(y_test_cls, y_pred_cls, target_names=categories))

# 混淆矩阵
print("Confusion Matrix...")
cm = metrics.confusion_matrix(y_test_cls, y_pred_cls)
print(cm)

time_dif = get_time_dif(start_time)
print("Time usage:", time_dif)

3. 模型训练测试结果

经过10轮的训练,训练集的准确率为98.4%,验证集的最佳准确率94.23%,可能跟数据集较小的缘故,训练收敛得比较快,并且仅仅用了11分钟。

image

模型在测试集的表现也尚可,达到了94.15%,除了文化类,其他类别的新闻预测准确率都达到了90%以上,召回率也表现不错。

image

训练过程准确率和损失的可视化结果可以在tensorboard中查看,命令行输入:tensorboard --logdir path/to/eventfile(是文件夹目录)。

准确率

损失

参考资源

  1. Chinese Word Vectors:目前最全的中文预训练词向量集合 | 机器之心
  2. 新闻上的文本分类:机器学习大乱斗 - 知乎
  3. gaussic/text-classification-cnn-rnn: CNN-RNN中文文本分类,基于TensorFlow
  4. 基于TensorFlow用卷积神经网络做文本分类 - 集智专栏
觉得还不错?赞助一下~
0%