##一、基于Keras的文本分类基本流程
本文以CAIL司法挑战赛的数据为例,叙述利用Keras框架进行文本分类的一般流程及基本的深度学习模型。
步骤 1:文本的预处理,分词->去除停用词->统计选择top n的词做为特征词
步骤 2:为每个特征词生成ID
步骤 3:将文本转化成ID序列,并将左侧补齐
步骤 4:训练集shuffle
步骤 5:Embedding Layer 将词转化为词向量
步骤 6:添加模型
步骤 7:训练模型
步骤 8:得到准确率
(如果使用TFIDF而非词向量进行文档表示,则直接分词去停后生成TFIDF矩阵后输入模型)
##二、文本预处理
###2.1 数据集说明
本文的数据集来自CAIL2018挑战赛,数据集是来自“中国裁判文书网”公开的刑事法律文书,其中每份数据由法律文书中的案情描述和事实部分组成,同时也包括每个案件所涉及的法条、被告人被判的罪名和刑期长短等要素。
数据集共包括268万刑法法律文书,共涉及202条罪名,183条法条,刑期长短包括0-25年、无期、死刑。
数据利用json格式储存,每一行为一条数据,每条数据均为一个字典。
比赛有三个任务,
任务一(罪名预测):根据刑事法律文书中的案情描述和事实部分,预测被告人被判的罪名;
任务二(法条推荐):根据刑事法律文书中的案情描述和事实部分,预测本案涉及的相关法条;
任务三(刑期预测):根据刑事法律文书中的案情描述和事实部分,预测被告人的刑期长短。
###2.2 读取数据集
将json中的文本和标签读取到list中,每个list的元素为一条文本/标签。
def read_train_data(path):
print('reading train data...')
fin = open(path, 'r', encoding='utf8')
alltext = []
accu_label = []
law_label = []
time_label = []
line = fin.readline()
while line:
d = json.loads(line)
alltext.append(d['fact'])
accu_label.append(get_label(d, 'accu'))
law_label.append(get_label(d, 'law'))
time_label.append(get_label(d, 'time'))
line = fin.readline()
fin.close()
return alltext, accu_label, law_label, time_label
然后对文本进行分词,因为后续要表示成词向量,是否去停意义不大,所以没有去停。分词后可以将分词后的文本每一条为一行存为txt,以免以后每次运行程序都要重新分词。
def cut_text(alltext):
print('cut text...')
count = 0
cut = thulac.thulac(seg_only=True)
train_text = []
for text in alltext:
count += 1
if count % 2000 == 0:
print(count)
train_text.append(cut.cut(text, text=True)) #分词结果以空格间隔,每个fact一个字符串
print(len(train_text))
print(train_text)
fileObject = codecs.open("./cuttext_all_large.txt", "w", "utf-8") #必须指定utf-8否则word2vec报错
for ip in train_text:
fileObject.write(ip)
fileObject.write('\n')
fileObject.close()
print('cut text over')
return train_text
用分词后的文本文档训练word2vec词向量模型并保存,这里使用了默认的size=100,即每个词由100维向量表示。
def word2vec_train():
print("start generate word2vec model...")
sentences = word2vec.Text8Corpus("cuttext_all_large.txt")
model = word2vec.Word2Vec(sentences) #默认size=100 ,100维
model.save('./predictor/model/word2vec')
print('finished and saved!')
return model
###2.3 使用Tokenizer将法律文书转换成数字特征
从txt中读取分好词的文本,转换成词袋序列。同样,tokenizer对象生成过程较慢,也可以通过pickle保存下来,以便下次训练或者测试时使用,具体tokenizer的用法及作用可以参见前文:Keras入门简介
最后得到一个文本矩阵sequences,每一行为一个用词编号序列表示的文本,有多少个文本就有多少列。
train_data = []
with open('./cuttext_all_large.txt') as f:
train_data = f.read().splitlines()
print(len(train_data))
# 转换成词袋序列
maxlen = 1500
# 词袋模型的最大特征束
max_features = 20000
# 设置分词最大个数 即词袋的单词个数
# with open('./predictor/model/tokenizer.pickle', 'rb') as f:
# tokenizer = pickle.load(f)
tokenizer = Tokenizer(num_words=max_features, lower=True) # 建立一个max_features个词的字典
tokenizer.fit_on_texts(train_data) # 使用一系列文档来生成token词典,参数为list类,每个元素为一个文档。可以将输入的文本中的每个词编号,编号是根据词频的,词频越大,编号越小。
global word_index
word_index = tokenizer.word_index # 长度为508242
# with open('./predictor/model/tokenizer_large.pickle', 'wb') as handle:
# pickle.dump(tokenizer, handle, protocol=pickle.HIGHEST_PROTOCOL)
# print("tokenizer has been saved.")
# self.tokenizer.fit_on_texts(train_data) # 使用一系列文档来生成token词典,参数为list类,每个元素为一个文档。可以将输入的文本中的每个词编号,编号是根据词频的,词频越大,编号越小。
sequences = tokenizer.texts_to_sequences(
train_data) # 对每个词编码之后,每个文本中的每个词就可以用对应的编码表示,即每条文本已经转变成一个向量了 将多个文档转换为word下标的向量形式,shape为[len(texts),len(text)] -- (文档数,每条文档的长度)
###2.4 让每句数字影评长度相同
x = sequence.pad_sequences(sequences, maxlen) # 将每条文本的长度设置一个固定值。
###2.5 使用Embedding层将每个词编码转换为词向量
调用Keras的Embedding层,该层只能作为模型的第一层,将每个词编码转换为词向量。
下面是最简单的形式,词向量随机初始化。max_features即每条文本取多少个单词表示,embedding_dims即每个单词由多少维向量表示。表示完后即得到一个三维向量。shape为(max_features)x(embedding_dims)x len(texts)(文档数)
Embedding(max_features, embedding_dims)
如果用预训练的word2vec词向量进行初始化,则需要先把训练好的模型转化为矩阵的形式,
model = gensim.models.Word2Vec.load('./predictor/model/word2vec')
word2idx = {"_PAD": 0} # 初始化 `[word : token]` 字典,后期 tokenize 语料库就是用该词典。
vocab_list = [(k, model.wv[k]) for k, v in model.wv.vocab.items()]
# 存储所有 word2vec 中所有向量的数组,留意其中多一位,词向量全为 0, 用于 padding
embeddings_matrix = np.zeros((len(model.wv.vocab.items()) + 1, model.vector_size))
print('Found %s word vectors.' % len(model.wv.vocab.items()))
for i in range(len(vocab_list)):
word = vocab_list[i][0]
word2idx[word] = i + 1
embeddings_matrix[i + 1] = vocab_list[i][1]
再令矩阵为Embedding的weight:
Embedding(len(embeddings_matrix), #表示文本数据中词汇的取值可能数,从语料库之中保留多少个单词。 因为Keras需要预留一个全零层, 所以+1
embedding_dims, # 嵌入单词的向量空间的大小。它为每个单词定义了这个层的输出向量的大小
weights=[embeddings_matrix], #构建一个[num_words, EMBEDDING_DIM]的矩阵,然后遍历word_index,将word在W2V模型之中对应vector复制过来。换个方式说:embedding_matrix 是原始W2V的子集,排列顺序按照Tokenizer在fit之后的词顺序。作为权重喂给Embedding Layer
input_length=maxlen, # 输入序列的长度,也就是一次输入带有的词汇个数
trainable=False # 我们设置 trainable = False,代表词向量不作为参数进行更新
)
##三、CNN模型搭建
CNN除了处理图像数据之外,还适用于文本分类。CNN模型首次使用在文本分类,是Yoon Kim发表的“Convolutional Neural Networks for Sentence Classification”论文中。
CNN的基本结构包括两层,其一为特征提取层,每个神经元的输入与前一层的局部接受域相连,并提取该局部的特征。一旦该局部特征被提取后,它与其它特征间的位置关系也随之确定下来;其二是特征映射层,网络的每个计算层由多个特征映射组成,每个特征映射是一个平面,平面上所有神经元的权值相等。特征映射结构采用影响函数核小的sigmoid函数作为卷积网络的激活函数,使得特征映射具有位移不变性。此外,由于一个映射面上的神经元共享权值,因而减少了网络自由参数的个数。卷积神经网络中的每一个卷积层都紧跟着一个用来求局部平均与二次提取的计算层,这种特有的两次特征提取结构减小了特征分辨率。
本节主要使用一维卷积核的CNN进行文本分类(二维卷积主要用于图像处理),keras使用序贯模型。
###3.1 基础版CNN
def baseline_model(y,max_features,embedding_dims,filters):
kernel_size = 3
model = Sequential()
model.add(Embedding(max_features, embedding_dims)) # 使用Embedding层将每个词编码转换为词向量
model.add(Conv1D(filters,
kernel_size,
padding='valid',
activation='relu',
strides=1))
# 池化
model.add(GlobalMaxPooling1D())
model.add(Dense(y.shape[1], activation='softmax')) #第一个参数units: 全连接层输出的维度,即下一层神经元的个数。
model.add(Dropout(0.2))
model.compile(loss='categorical_crossentropy',
optimizer='adam',
metrics=['accuracy'])
model.summary()
return model
###3.2 简单版textCNN
这是省略掉多通道和微调的简单版textCNN,用了四个卷积核:
def test_cnn(y,maxlen,max_features,embedding_dims,filters = 250):
#Inputs
seq = Input(shape=[maxlen],name='x_seq')
#Embedding layers
emb = Embedding(max_features,embedding_dims)(seq)
# conv layers
convs = []
filter_sizes = [2,3,4,5]
for fsz in filter_sizes:
conv1 = Conv1D(filters,kernel_size=fsz,activation='tanh')(emb)
pool1 = MaxPooling1D(maxlen-fsz+1)(conv1)
pool1 = Flatten()(pool1)
convs.append(pool1)
merge = concatenate(convs,axis=1)
out = Dropout(0.5)(merge)
output = Dense(32,activation='relu')(out)
output = Dense(units=y.shape[1],activation='sigmoid')(output)
model = Model([seq],output)
model.compile(loss='categorical_crossentropy',optimizer='adam',metrics=['accuracy'])
return model
###3.3 使用了word2vec词向量的CNN:
def cnn_w2v(y,max_features,embedding_dims,filters,maxlen):
# CNN参数
kernel_size = 3
model = gensim.models.Word2Vec.load('./predictor/model/word2vec')
word2idx = {"_PAD": 0} # 初始化 `[word : token]` 字典,后期 tokenize 语料库就是用该词典。
vocab_list = [(k, model.wv[k]) for k, v in model.wv.vocab.items()]
# 存储所有 word2vec 中所有向量的数组,留意其中多一位,词向量全为 0, 用于 padding
embeddings_matrix = np.zeros((len(model.wv.vocab.items()) + 1, model.vector_size))
print('Found %s word vectors.' % len(model.wv.vocab.items()))
for i in range(len(vocab_list)):
word = vocab_list[i][0]
word2idx[word] = i + 1
embeddings_matrix[i + 1] = vocab_list[i][1]
model = Sequential()
# 使用Embedding层将每个词编码转换为词向量
model.add(Embedding(len(embeddings_matrix), #表示文本数据中词汇的取值可能数,从语料库之中保留多少个单词。 因为Keras需要预留一个全零层, 所以+1
embedding_dims, # 嵌入单词的向量空间的大小。它为每个单词定义了这个层的输出向量的大小
weights=[embeddings_matrix], #构建一个[num_words, EMBEDDING_DIM]的矩阵,然后遍历word_index,将word在W2V模型之中对应vector复制过来。换个方式说:embedding_matrix 是原始W2V的子集,排列顺序按照Tokenizer在fit之后的词顺序。作为权重喂给Embedding Layer
input_length=maxlen, # 输入序列的长度,也就是一次输入带有的词汇个数
trainable=False # 我们设置 trainable = False,代表词向量不作为参数进行更新
))
model.add(Conv1D(filters,
kernel_size,
padding='valid',
activation='relu',
strides=1))
# 池化
model.add(GlobalMaxPooling1D())
model.add(Dense(y.shape[1], activation='softmax')) #第一个参数units: 全连接层输出的维度,即下一层神经元的个数。
model.add(Dropout(0.2))
model.compile(loss='categorical_crossentropy',
optimizer='adam',
metrics=['accuracy'])
model.summary()
return model
##四、模型训练与测试
因为是多分类问题,这部分主要是训练前对标签的one-hot处理和对训练数据的打乱。
训练时使用了early stopping。
最后保存模型。
def runcnn(x,label, label_name):
y = np_utils.to_categorical(label) #多分类时,此方法将1,2,3,4,....这样的分类转化成one-hot 向量的形式,最终使用softmax做为输出
print(x.shape,y.shape)
indices = np.arange(len(x))
lenofdata = len(x)
np.random.shuffle(indices)
x_train = x[indices][:int(lenofdata*0.8)]
y_train = y[indices][:int(lenofdata*0.8)]
x_test = x[indices][int(lenofdata*0.8):]
y_test = y[indices][int(lenofdata*0.8):]
model = baseline_model(y)
keras.callbacks.EarlyStopping(
monitor='val_loss',
patience=0,
verbose=0,
mode='auto')
print("training model")
history = model.fit(x_train,y_train,validation_split=0.2,batch_size=64,epochs=10,verbose=2,shuffle=True)
accy=history.history['acc']
np_accy=np.array(accy)
np.savetxt('save.txt',np_accy)
print("pridicting...")
scores = model.evaluate(x_test,y_test)
print('test_loss:%f,accuracy: %f'%(scores[0],scores[1]))
print("saving %s_textcnnmodel" % label_name)
model.save('./predictor/model/%s_cnn_large.h5' % label_name)
##五、常见问题
###5.1 如何利用Keras处理超过机器内存的数据集?
可以使用model.train_on_batch(X,y)
和model.test_on_batch(X,y)
。或编写一个每次产生一个batch样本的生成器函数,并调用model.fit_generator(data_generator, samples_per_epoch, nb_epoch)
进行训练。
###5.2 如何保存Keras模型?
官方文档推荐使用model.save(filepath)
,将Keras模型和权重保存在一个HDF5文件中,该文件将包含:
模型的结构,以便重构该模型
模型的权重
训练配置(损失函数,优化器等)
优化器的状态,以便于从上次训练中断的地方开始
使用keras.models.load_model(filepath)
来重新实例化你的模型,如果文件中存储了训练配置的话,该函数还会同时完成模型的编译.
###5.3 如何将Tokenizer对象保存到文件以进行评分?
很多比赛提交模型后用测试集进行评分,如果不保存Tokenizer对象,则需要在对每一个句子评分的时候都重新加载整个语料库并生成Tokenizer对象。在网上找到的保存方法是使用pickle或joblib,使用pickle保存的代码如下:
import pickle
# saving
with open('tokenizer.pickle', 'wb') as handle:
pickle.dump(tokenizer, handle, protocol=pickle.HIGHEST_PROTOCOL)
# loading
with open('tokenizer.pickle', 'rb') as handle:
tokenizer = pickle.load(handle)
###5.4 如何在多张GPU卡上使用Keras?
官方建议有多张GPU卡可用时,使用TnesorFlow后端。有两种方法可以在多张GPU上运行一个模型:数据并行/设备并行
大多数情况下,你需要的很可能是“数据并行”数据并行
数据并行将目标模型在多个设备上各复制一份,并使用每个设备上的复制品处理整个数据集的不同部分数据。Keras在keras.utils.multi_gpu_model中提供有内置函数,该函数可以产生任意模型的数据并行版本,最高支持在8片GPU上并行。 请参考utils中的multi_gpu_model文档。
设备并行
设备并行是在不同设备上运行同一个模型的不同部分,当模型含有多个并行结构,例如含有两个分支时,这种方式很适合。
###5.5 如何在执行程序时设置使用的GPU?
首先可以用nvidia-smi
命令在服务器上查看GPU使用情况,如果要在python代码中设置使用的GPU(如使用pycharm进行调试时),可以使用下面的代码
import os
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
###5.6 多分类问题应怎样设置?
如出现下列错误,
ValueError: Error when checking target: expected dense_2 to have shape (None, 1) but got array with shape (123673, 202)
可能是多分类label设置的问题。
多分类问题的类别设置与单分类问题不同之处在于以下几点:
y = np_utils.to_categorical(accu_label)
设置成one-hot的形式;Dense(y.shape[1], activation='softmax')
;loss="categorical_crossentropy"
。###遇到的其他问题
####问题1
File “/usr/local/lib/python3.5/dist-packages/keras/preprocessing/text.py”, line 267, in texts_to_sequences for vect in self.texts_to_sequences_generator(texts): File “/usr/local/lib/python3.5/dist-packages/keras/preprocessing/text.py”,
line 302, in texts_to_sequences_generator elif self.oov_token is not None: AttributeError: ‘Tokenizer’ object has no attribute ‘oov_token’
查看keras2.1.1版本的源码发现texts_to_sequences_generator
中没有oov_token,手动设置tokenizer.oov_token = None
来解决这个问题。
Pickle并不是序列化对象的可靠方法,因为它假定您导入的底层Python代码/模块没有改变。通常,不要使用与pickle时使用的库版本不同的pickle对象。这不是Keras问题,而是一个通用的Python/Pickle问题。在这种情况下,有一个简单的修复(设置属性),但是在很多情况下不会。
参考:https://stackoverflow.com/questions/49861842/attributeerror-tokenizer-object-has-no-attribute-oov-token-in-keras
####问题2
softmax() got an unexpected keyword argument ‘axis’
将keras升级到2.1.6之后TensorFlow和keras的版本不一致。
github地址:https://github.com/vivianLL/textClassification_Keras