文本情感分类(三):分词 OR 不分词
By 苏剑林 | 2016-06-29 | 412784位读者 |去年泰迪杯竞赛过后,笔者写了一篇简要介绍深度学习在情感分析中的应用的博文《文本情感分类(二):深度学习模型》。虽然文章很粗糙,但还是得到了不少读者的反响,让我颇为意外。然而,那篇文章中在实现上有些不清楚的地方,这是因为:1、在那篇文章以后,keras已经做了比较大的改动,原来的代码不通用了;2、里边的代码可能经过我随手改动过,所以发出来的时候不是最适当的版本。因此,在近一年之后,我再重拾这个话题,并且完成一些之前没有完成的测试。
为什么要用深度学习模型?除了它更高精度等原因之外,还有一个重要原因,那就是它是目前唯一的能够实现“端到端”的模型。所谓“端到端”,就是能够直接将原始数据和标签输入,然后让模型自己完成一切过程——包括特征的提取、模型的学习。而回顾我们做中文情感分类的过程,一般都是“分词——词向量——句向量(LSTM)——分类”这么几个步骤。虽然很多时候这种模型已经达到了state of art的效果,但是有些疑问还是需要进一步测试解决的。对于中文来说,字才是最低粒度的文字单位,因此从“端到端”的角度来看,应该将直接将句子以字的方式进行输入,而不是先将句子分好词。那到底有没有分词的必要性呢?本文测试比较了字one hot、字向量、词向量三者之间的效果。
模型测试 #
本文测试了三个模型,或者说,是三套框架,具体代码在文末给出。这三套框架分别是:
1、one hot:以字为单位,不分词,将每个句子截断为200字(不够则补空字符串),然后将句子以“字-one hot”的矩阵形式输入到LSTM模型中进行学习分类;
2、one embedding:以字为单位,不分词,,将每个句子截断为200字(不够则补空字符串),然后将句子以“字-字向量(embedding)“的矩阵形式输入到LSTM模型中进行学习分类;
3、word embedding:以词为单位,分词,,将每个句子截断为100词(不够则补空字符串),然后将句子以“词-词向量(embedding)”的矩阵形式输入到LSTM模型中进行学习分类。
其中所用的LSTM模型结构是类似的。所用的语料还是《文本情感分类(二):深度学习模型》中的语料,以15000条进行训练,剩下的6000条左右做测试。意外的是,三个模型都取得了相近的结果。
$$\begin{array}{c|ccc}
\hline
&\text{one hot} & \text{one embedding} & \text{word embedding}\\
\hline
\text{迭代次数} & 90 & 30 & 30\\
\text{每轮用时} & 100s & 36s & 18s\\
\text{训练准确率} & 96.60\% & 95.95\% & 98.41\% \\
\text{测试准确率} & 89.21\% & 89.55\% & 89.03\% \\
\hline
\end{array}$$
可见,在准确率方面,三者是类似的,区分度不大。不管是用one hot、字向量还是词向量,结果都差不多。也许用《文本情感分类(二):深度学习模型》的方法来为每个模型选取适当的阈值,会使得测试准确率更高一些,但模型之间的相对准确率应该不会变化很大。
当然,测试本身可能存在一些不公平的情况,也许会导致测试结果不公平,而我也没有反复去测试。比如one hot的模型迭代了90次,其它两个模型是30次,因为one hot模型所构造的样本维度太大,需要经过更长时间才出现收敛现象,而且训练过程中,准确率是波动上升的,并非像其它两个模型那样稳定上升。事实上这是所有one hot模型的共同特点。
多扯一点 #
看上去,one hot模型的确存在维度灾难的问题,而且训练时间又长,效果又没有明显提升,那是否就说明没有研究one hot表示的必要了呢?
我觉得不是这样的。当初大家诟病one hot模型的原因,除了维度灾难之外,还有一个就是“语义鸿沟”,也就说任意两个词之间没有任何相关性(不管用欧式距离还是余弦相似度,任意两个词的计算结果是一样的)。可是,这一点假设用在词语中不成立,可是用在中文的“字”上面,不是很合理吗?汉字单独成词的例子不多,大多数是二字词,也就是说,任意两个字之间没有任何相关性,这个假设在汉字的“字”的层面上,是近似成立的!而后面我们用了LSTM,LSTM本身具有整合邻近数据的功能,因此,它暗含了将字整合为词的过程。
此外,one hot模型还有一个非常重要的特点——它没有任何信息损失——从one hot的编码结果中,我们反过来解码出原来那句话是哪些字词组成的,然而,我无法从一个词向量中确定原来的词是什么。这些观点都表明,在很多情况下,one hot模型都是很有价值的。
而我们为什么用词向量呢?词向量相当于做了一个假设:每个词具有比较确定的意思。这个假设在词语层面也是近似成立的,毕竟一词多义的词语相对来说也不多。正因为如此,我们才可以将词放到一个较低维度的实数空间里,用一个实数向量来表示一个词语,并且用它们之间的距离或者余弦相似度来表示词语之间的相似度。这也是词向量能够解决“一义多词”而没法解决“一词多义”的原因。
从这样看来,上面三个模型中,只有one hot和word embedding才是理论上说得过去的,而one embedding则看上去变得不伦不类了,因为字似乎不能说具有比较确定的意思。但为什么one embedding效果也还不错?我估计,这可能是因为二元分类问题本身是一个很粗糙的分类(0或1),如果更多元的分类,可能one embedding的方式效果就降下来了。不过,我也没有进行更多的测试了,因为太耗时间了。
当然,这只能算是我的主观臆测,还望大家指正。尤其是one embedding部分的评价,是值得商榷的。
代码来了 #
可能大家并不想看我胡扯一通,是直接来看代码的,现奉上三个模型的代码。最好有GPU加速,尤其是试验one hot模型,不然慢到哭了。
模型1:one hot
# -*- coding:utf-8 -*-
'''
one hot测试
在GTX960上,约100s一轮
经过90轮迭代,训练集准确率为96.60%,测试集准确率为89.21%
Dropout不能用太多,否则信息损失太严重
'''
import numpy as np
import pandas as pd
pos = pd.read_excel('pos.xls', header=None)
pos['label'] = 1
neg = pd.read_excel('neg.xls', header=None)
neg['label'] = 0
all_ = pos.append(neg, ignore_index=True)
maxlen = 200 #截断字数
min_count = 20 #出现次数少于该值的字扔掉。这是最简单的降维方法
content = ''.join(all_[0])
abc = pd.Series(list(content)).value_counts()
abc = abc[abc >= min_count]
abc[:] = list(range(len(abc)))
word_set = set(abc.index)
def doc2num(s, maxlen):
s = [i for i in s if i in word_set]
s = s[:maxlen]
return list(abc[s])
all_['doc2num'] = all_[0].apply(lambda s: doc2num(s, maxlen))
#手动打乱数据
#当然也可以把这部分加入到生成器中
idx = list(range(len(all_)))
np.random.shuffle(idx)
all_ = all_.loc[idx]
#按keras的输入要求来生成数据
x = np.array(list(all_['doc2num']))
y = np.array(list(all_['label']))
y = y.reshape((-1,1)) #调整标签形状
from keras.utils import np_utils
from keras.models import Sequential
from keras.layers import Dense, Activation, Dropout
from keras.layers import LSTM
import sys
sys.setrecursionlimit(10000) #增大堆栈最大深度(递归深度),据说默认为1000,报错
#建立模型
model = Sequential()
model.add(LSTM(128, input_shape=(maxlen,len(abc))))
model.add(Dropout(0.5))
model.add(Dense(1))
model.add(Activation('sigmoid'))
model.compile(loss='binary_crossentropy',
optimizer='rmsprop',
metrics=['accuracy'])
#单个one hot矩阵的大小是maxlen*len(abc)的,非常消耗内存
#为了方便低内存的PC进行测试,这里使用了生成器的方式来生成one hot矩阵
#仅在调用时才生成one hot矩阵
#可以通过减少batch_size来降低内存使用,但会相应地增加一定的训练时间
batch_size = 128
train_num = 15000
#不足则补全0行
gen_matrix = lambda z: np.vstack((np_utils.to_categorical(z, len(abc)), np.zeros((maxlen-len(z), len(abc)))))
def data_generator(data, labels, batch_size):
batches = [list(range(batch_size*i, min(len(data), batch_size*(i+1)))) for i in range(len(data)/batch_size+1)]
while True:
for i in batches:
xx = np.zeros((maxlen, len(abc)))
xx, yy = np.array(map(gen_matrix, data[i])), labels[i]
yield (xx, yy)
model.fit_generator(data_generator(x[:train_num], y[:train_num], batch_size), samples_per_epoch=train_num, nb_epoch=30)
model.evaluate_generator(data_generator(x[train_num:], y[train_num:], batch_size), val_samples=len(x[train_num:]))
def predict_one(s): #单个句子的预测函数
s = gen_matrix(doc2num(s, maxlen))
s = s.reshape((1, s.shape[0], s.shape[1]))
return model.predict_classes(s, verbose=0)[0][0]
模型2:one embedding
# -*- coding:utf-8 -*-
'''
one embedding测试
在GTX960上,36s一轮
经过30轮迭代,训练集准确率为95.95%,测试集准确率为89.55%
Dropout不能用太多,否则信息损失太严重
'''
import numpy as np
import pandas as pd
pos = pd.read_excel('pos.xls', header=None)
pos['label'] = 1
neg = pd.read_excel('neg.xls', header=None)
neg['label'] = 0
all_ = pos.append(neg, ignore_index=True)
maxlen = 200 #截断字数
min_count = 20 #出现次数少于该值的字扔掉。这是最简单的降维方法
content = ''.join(all_[0])
abc = pd.Series(list(content)).value_counts()
abc = abc[abc >= min_count]
abc[:] = list(range(1, len(abc)+1))
abc[''] = 0 #添加空字符串用来补全
word_set = set(abc.index)
def doc2num(s, maxlen):
s = [i for i in s if i in word_set]
s = s[:maxlen] + ['']*max(0, maxlen-len(s))
return list(abc[s])
all_['doc2num'] = all_[0].apply(lambda s: doc2num(s, maxlen))
#手动打乱数据
idx = list(range(len(all_)))
np.random.shuffle(idx)
all_ = all_.loc[idx]
#按keras的输入要求来生成数据
x = np.array(list(all_['doc2num']))
y = np.array(list(all_['label']))
y = y.reshape((-1,1)) #调整标签形状
from keras.models import Sequential
from keras.layers import Dense, Activation, Dropout, Embedding
from keras.layers import LSTM
#建立模型
model = Sequential()
model.add(Embedding(len(abc), 256, input_length=maxlen))
model.add(LSTM(128))
model.add(Dropout(0.5))
model.add(Dense(1))
model.add(Activation('sigmoid'))
model.compile(loss='binary_crossentropy',
optimizer='adam',
metrics=['accuracy'])
batch_size = 128
train_num = 15000
model.fit(x[:train_num], y[:train_num], batch_size = batch_size, nb_epoch=30)
model.evaluate(x[train_num:], y[train_num:], batch_size = batch_size)
def predict_one(s): #单个句子的预测函数
s = np.array(doc2num(s, maxlen))
s = s.reshape((1, s.shape[0]))
return model.predict_classes(s, verbose=0)[0][0]
模型3:word embedding
# -*- coding:utf-8 -*-
'''
word embedding测试
在GTX960上,18s一轮
经过30轮迭代,训练集准确率为98.41%,测试集准确率为89.03%
Dropout不能用太多,否则信息损失太严重
'''
import numpy as np
import pandas as pd
import jieba
pos = pd.read_excel('pos.xls', header=None)
pos['label'] = 1
neg = pd.read_excel('neg.xls', header=None)
neg['label'] = 0
all_ = pos.append(neg, ignore_index=True)
all_['words'] = all_[0].apply(lambda s: list(jieba.cut(s))) #调用结巴分词
maxlen = 100 #截断词数
min_count = 5 #出现次数少于该值的词扔掉。这是最简单的降维方法
content = []
for i in all_['words']:
content.extend(i)
abc = pd.Series(content).value_counts()
abc = abc[abc >= min_count]
abc[:] = list(range(1, len(abc)+1))
abc[''] = 0 #添加空字符串用来补全
word_set = set(abc.index)
def doc2num(s, maxlen):
s = [i for i in s if i in word_set]
s = s[:maxlen] + ['']*max(0, maxlen-len(s))
return list(abc[s])
all_['doc2num'] = all_['words'].apply(lambda s: doc2num(s, maxlen))
#手动打乱数据
idx = list(range(len(all_)))
np.random.shuffle(idx)
all_ = all_.loc[idx]
#按keras的输入要求来生成数据
x = np.array(list(all_['doc2num']))
y = np.array(list(all_['label']))
y = y.reshape((-1,1)) #调整标签形状
from keras.models import Sequential
from keras.layers import Dense, Activation, Dropout, Embedding
from keras.layers import LSTM
#建立模型
model = Sequential()
model.add(Embedding(len(abc), 256, input_length=maxlen))
model.add(LSTM(128))
model.add(Dropout(0.5))
model.add(Dense(1))
model.add(Activation('sigmoid'))
model.compile(loss='binary_crossentropy',
optimizer='adam',
metrics=['accuracy'])
batch_size = 128
train_num = 15000
model.fit(x[:train_num], y[:train_num], batch_size = batch_size, nb_epoch=30)
model.evaluate(x[train_num:], y[train_num:], batch_size = batch_size)
def predict_one(s): #单个句子的预测函数
s = np.array(doc2num(list(jieba.cut(s)), maxlen))
s = s.reshape((1, s.shape[0]))
return model.predict_classes(s, verbose=0)[0][0]
转载到请包括本文地址:https://kexue.fm/archives/3863
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Jun. 29, 2016). 《文本情感分类(三):分词 OR 不分词 》[Blog post]. Retrieved from https://kexue.fm/archives/3863
@online{kexuefm-3863,
title={文本情感分类(三):分词 OR 不分词},
author={苏剑林},
year={2016},
month={Jun},
url={\url{https://kexue.fm/archives/3863}},
}
September 12th, 2016
非常感谢,请问您还有关于LSTM的代码示例么(除了您的文本情感分类 三)
在右边的搜索框搜索lstm
September 16th, 2016
博主,你好。对于整篇文章的正负情感倾向的分类有什么好的思路?我暂时是想一句句的分析得出每句话的正负情感倾向值,正向为1,负向为0,最后累加得到每篇文章的值。不知道效果怎样?
我没做过,你可以尝试一下。事实上,对于文档级别的分类问题,目前没有定式方式,各种思路都可以尝试一下,以效果说话。
September 20th, 2016
为何我在试了word embedding的代码后,发现比更不是博主所说的那种loss稳定下降,跳跃也非常厉害
并且最终训练集结果只有0.8751,不知道是否与每次shuffle的不同有关,但效果不太好
September 26th, 2016
请问博主,从词语到神经网络的输入中间的查表映射层是怎么做的,有相关参考资料k推荐么,如果提前用word2vec训练好了词向量又该怎么映射查表呢? 麻烦博主了
embedding层是根据你后面的任务训练的,不管什么时候,词向量都只是副产品。
预训练好词向量,可以用fit_generator的方法自己调用,也可以参考这里加入到embedding层中
https://github.com/fchollet/keras/blob/master/examples/pretrained_word_embeddings.py
建议多看官方文档和例子
September 26th, 2016
博主,请教一个问题:
def doc2num(s, maxlen):
s = [i for i in s if i in abc.index]
s = s[:maxlen] + ['']*max(0, maxlen-len(s))
return list(abc[s])
all_['doc2num'] = all_[0].apply(lambda s: doc2num(s, maxlen)) #这句代码中的all_[0]是未经分词的评论文本,那么上面函数中的s就是单个字组成的列表了。按照我的理解,上面函数的作用应该是对分词后的一条评论的编号处理吧?
也许是我理解有误,请博主能够给予讲解。
你的理解是正确的,就是编号而已。
博主,我的意思是说,你的代码里面all_['doc2num'] = all_[0].apply(lambda s: doc2num(s, maxlen)) 是不是应该改为all_['doc2num'] = all_["words"].apply(lambda s: doc2num(s, maxlen))
是的,我发现了~已经改过来,谢谢。
October 18th, 2016
博主你好之前代码是好用的,但是现在提示:ImportError: cannot import name nvcc_compiler。如何解决?
你使用theano么?重新安装theano试试看?
November 16th, 2016
大神能不能看看 这个模型的问题出在哪里
from keras.layers import Merge
import numpy as np
np.random.seed(1337)
from keras.preprocessing import sequence
from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation
from keras.layers import Embedding
from keras.layers import Convolution1D, GlobalMaxPooling1D
from keras.layers import LSTM
from keras.datasets import imdb
from keras import backend as K
max_features = 5000
maxlen = 400
batch_size = 32
embedding_dims = 50
nb_filter = 250
filter_length = 3
hidden_dims = 250
nb_epoch = 2
print('Loading data...')
(X_train, y_train), (X_test, y_test) = imdb.load_data(nb_words=max_features)
print(len(X_train), 'train sequences')
print(len(X_test), 'test sequences')
print('Pad sequences (samples x time)')
X_train = sequence.pad_sequences(X_train, maxlen=maxlen)
X_test = sequence.pad_sequences(X_test, maxlen=maxlen)
print('X_train shape:', X_train.shape)
print('X_test shape:', X_test.shape)
print('Build model...')
right_branch = Sequential()
right_branch.add(Embedding(max_features,
embedding_dims,
input_length=maxlen,
dropout=0.2))
right_branch.add(Convolution1D(
nb_filter=nb_filter,
filter_length=filter_length,
border_mode='valid',
activation='relu',
subsample_length=1))
left_branch = Sequential()
left_branch.add(Embedding(max_features,
embedding_dims,
input_length=maxlen,
dropout=0.2))
left_branch.add(LSTM(398, dropout_W=0.5, dropout_U=0.5,return_sequences=False))
def test(x):
return K.dot(x[0],x[1])
merged = Merge([left_branch,right_branch1], mode=test,output_shape=lambda x: x[0])
final_model = Sequential()
final_model.add(merged)
final_model.add(Dense(1))
final_model.add(Activation('sigmoid'))
final_model.compile(loss='binary_crossentropy',
optimizer='adam',
metrics=['accuracy'])
final_model.fit([X_train,X_train], y_train,
batch_size=batch_size,
nb_epoch=nb_epoch,
validation_data=([X_test,X_test], y_test)
)
December 24th, 2016
博主,辛苦啦,看到您的文章很有细致,谢谢!
想请问有没有时间序列分类的相关资料呢,我最近有个课题是分析一个电力网络若干个节点的电压数据,主要想研究两个问题:
第一个是:对每个节点,有10个月,每5分钟采样的电压数据序列,想通过把一定时段序列特征提取,比如按每天,每周,然后对于新的一天,一周数据进行检测。原来想到的是:首先把超出一定阈值的电压作为特征点,然后构造词袋,然后进行匹配。现在看到很多时间序列都是用lstm来做的。
第二个是:引入空间概念,结合图论里的分区,将不同节点产生的异常数据的空间关系进行分析。这方面想往图像的特征识别上研究。
想请博主给予讲解~~~。~~~
时间序列分析一般用lstm吧?lstm就是设计用来做时间序列分析的,你可以试试,就跟处理句子序列是一样的,而且还省去了embedding层。只是我觉得,lstm似乎过于黑盒了,而且需要数据量比较大才有效,我一直的观点都是,时间序列数据还是觉得用可解释性比较强的模型比较好。
December 26th, 2016
玩到最后却有个比较小白的问题想请博主请教。
拿word embedding的代码来说,就算上embedding层(文档说这个层只能是第一个层)、递归层和输出层。来看这属于一个三层的神经网络。
但是按keras框架来说,您在代码也加入Dropout层,这个能算作单独的一层么?叫他为四层的神经网络?
看大神也有构建多层的神经网络的文章,但还是想请教一下如果选择构建隐藏层的层数及神经元的个数的经验。
这个只能是实验吧,另外就是在效果差不多的情形下,尽可能减少参数量。你可以用model.summary()观察每一层的参数量,对参数多的层作适当调整。
dropout只在训练的时候生效,预测的时候就没有了,严格来说不算。递归层本身来说就是一个深度层了,跟普通的感知层不一样,诸如lstm之类的,相当于还有子模型来控制各个“门”,因此,不能简单地以层数来说话了。
谢谢您!
December 28th, 2016
还有一个问题,有什么方法把训练好的模型持久化,比如下次直接加载训练好的模型,然后调用类似于predict_one()这个方法然后可以直接出结果呢?