访问flyai.club,一键创建你的人工智能项目
作者 | 笔墨留年
https://blog.csdn.net/aaronjny/article/details/79561115
作为对循环神经网络的实践,我用循环神经网络做了个影评情感的分类,即判断影评的感情色彩是正面的,还是负面的。
选择使用RNN来做情感分类,主要是因为影评是一段文字,是序列的,而RNN对序列的支持比较好,能够“记忆”前文。虽然可以提取特征词向量,然后交给传统机器学习模型或全连接神经网络去做,也能取得很好的效果,但只从端对端的角度来看的话,RNN无疑是最合适的。
以下介绍实现过程。
一、数据预处理
本文中使用的训练数据集为https://www.cs.cornell.edu/people/pabo/movie-review-data/上的sentence polarity dataset v1.0,包含正负面评论各5331条。可以点击进行下载。
数据下载下来之后需要进行解压,得到rt-polarity.neg和rt-polarity.pos文件,这两个文件是Windows-1252编码的,先将它转成unicode处理起来会更方便。
补充一下小知识,当我们打开一个文件,发现乱码,却又不知道该文件的编码是什么的时候,可以使用python的chardet类库进行判断,这里的Windows-1252就是使用该类库检测出来的。
在数据预处理部分,我们要完成如下处理过程:
1.转码
即将文件转为unicode编码,方便我们后续操作。读取文件,转换编码,重新写入到新文件即可。不存在技术难点。
2.生成词汇表
读取训练文件,提取出所有的单词,并统计各个单词出现的次数。为了避免低频词的干扰,同时减少模型参数,我们只保留部分高频词,比如这里我只保存出现次数前9999个,同时将低频词标识符加入到词汇表中。
3.借助词汇表将影评转化为词向量
单词是没法直接输入给模型的,所以我们需要将词汇表中的每个单词对应于一个编号,将影评数据转化成词向量。方便后面生成词嵌入矩阵。
4.填充词向量并转化为np数组
因为不同评论的长度是不同的,我们要组成batch进行训练,就需要先将其长度统一。这里我选择以最长的影评为标准,对其他较短的影评的空白部分进行填充。然后将其转化成numpy的数组。
5.按比例划分数据集
按照机器学习的惯例,数据集应被划分为三份,即训练集、开发集和测试集。当然,有时也会只划分两份,即只包括训练集和开发集。
这里我划分成三份,训练集、开发集和测试集的占比为[0.8,0.1,0.1]。划分的方式为轮盘赌法,在numpy中可以使用cumsum和searchsorted来简洁地实现轮盘赌法。
6.打乱数据集,写入文件
为了取得更好的训练效果,将数据集随机打乱。为了保证在训练和模型调整的过程中训练集、开发集、测试集不发生改变,将三个数据集写入到文件中,使用的时候从文件中读取。
下面贴上数据预处理的代码,注释写的很细,就不多说了。
# -*- coding: utf-8 -*-# @Time : 18-3-14 下午2:28# @Author : AaronJny# @Email : [email protected](sys)sys.setdefaultencoding('utf8')importcollectionsimportsettingsimportutilsimportnumpyasnpdefcreate_vocab():"""
创建词汇表,写入文件中
:return:
"""# 存放出现的所有单词word_list = []# 从文件中读取数据,拆分单词withopen(settings.NEG_TXT,'r')asf: f_lines = f.readlines()forlineinf_lines: words = line.strip().split() word_list.extend(words)withopen(settings.POS_TXT,'r')asf: f_lines = f.readlines()forlineinf_lines: words = line.strip().split() word_list.extend(words)# 统计单词出现的次数counter = collections.Counter(word_list) sorted_words = sorted(counter.items(), key=lambdax: x[1], reverse=True)# 选取高频词word_list = [word[0]forwordinsorted_words] word_list = [''] + word_list[:settings.VOCAB_SIZE -1]# 将词汇表写入文件中withopen(settings.VOCAB_PATH,'w')asf:forwordinword_list: f.write(word +'\n')defcreate_vec(txt_path, vec_path):"""
根据词汇表生成词向量
:param txt_path: 影评文件路径
:param vec_path: 输出词向量路径
:return:
"""# 获取单词到编号的映射word2id = utils.read_word_to_id_dict()# 将语句转化成向量vec = []withopen(txt_path,'r')asf: f_lines = f.readlines()forlineinf_lines: tmp_vec = [str(utils.get_id_by_word(word, word2id))forwordinline.strip().split()] vec.append(tmp_vec)# 写入文件中withopen(vec_path,'w')asf:fortmp_vecinvec: f.write(' '.join(tmp_vec) +'\n')defcut_train_dev_test():"""
使用轮盘赌法,划分训练集、开发集和测试集
打乱,并写入不同文件中
:return:
"""# 三个位置分别存放训练、开发、测试data = [[], [], []] labels = [[], [], []]# 累加概率 rate [0.8,0.1,0.1] cumsum_rate [0.8,0.9,1.0]rate = np.array([settings.TRAIN_RATE, settings.DEV_RATE, settings.TEST_RATE]) cumsum_rate = np.cumsum(rate)# 使用轮盘赌法划分数据集withopen(settings.POS_VEC,'r')asf: f_lines = f.readlines()forlineinf_lines: tmp_data = [int(word)forwordinline.strip().split()] tmp_label = [1, ] index = int(np.searchsorted(cumsum_rate, np.random.rand(1) *1.0)) data[index].append(tmp_data) labels[index].append(tmp_label)withopen(settings.NEG_VEC,'r')asf: f_lines = f.readlines()forlineinf_lines: tmp_data = [int(word)forwordinline.strip().split()] tmp_label = [0, ] index = int(np.searchsorted(cumsum_rate, np.random.rand(1) *1.0)) data[index].append(tmp_data) labels[index].append(tmp_label)# 计算一下实际上分割出来的比例print'最终分割比例', np.array([map(len, data)], dtype=np.float32) / sum(map(len, data))# 打乱数据,写入到文件中shuffle_data(data[0], labels[0], settings.TRAIN_DATA) shuffle_data(data[1], labels[1], settings.DEV_DATA) shuffle_data(data[2], labels[2], settings.TEST_DATA)defshuffle_data(x, y, path):"""
填充数据,生成np数组
打乱数据,写入文件中
:param x: 数据
:param y: 标签
:param path: 保存路径
:return:
"""# 计算影评的最大长度maxlen = max(map(len, x))# 填充数据data = np.zeros([len(x), maxlen], dtype=np.int32)forrowinrange(len(x)): data[row, :len(x[row])] = x[row] label = np.array(y)# 打乱数据state = np.random.get_state() np.random.shuffle(data) np.random.set_state(state) np.random.shuffle(label)# 保存数据np.save(path +'_data', data) np.save(path +'_labels', label)defdecode_file(infile, outfile):"""
将文件的编码从'Windows-1252'转为Unicode
:param infile: 输入文件路径
:param outfile: 输出文件路径
:return:
"""withopen(infile,'r')asf: txt = f.read().decode('Windows-1252')withopen(outfile,'w')asf: f.write(txt)if__name__ =='__main__':# 解码文件decode_file(settings.ORIGIN_POS, settings.POS_TXT) decode_file(settings.ORIGIN_NEG, settings.NEG_TXT)# 创建词汇表create_vocab()# 生成词向量create_vec(settings.NEG_TXT, settings.NEG_VEC) create_vec(settings.POS_TXT, settings.POS_VEC)# 划分数据集cut_train_dev_test()
二、模型编写
数据处理好之后,开始模型的编写。这里选用循环神经网络,建模过程大致如下:
1.使用embedding构建词嵌入矩阵
在数据预处理中,我们将影评处理成了一个个单词编号构成的向量,也就是说,一条影评,对应于一个由单词编号构成的向量。
将这样的向量进行embedding,即可构建出词嵌入矩阵。在词嵌入矩阵中,每个词由一个向量表示,矩阵中不同向量之间的差异对应于它们表示的词之间的差异。
2.使用LSTM作为循环神经网络的基本单元
长短时记忆网络(LSTM)能够自动完成前文信息的“记忆”和“遗忘”,在循环神经网络中表现良好,已经成为在循环神经网络中大部分人的首选。这里我选择使用LSTM作为循环神经网络的基本单元。
3.对embedding和LSTM进行随机失活(dropout)
为了提高模型的泛化能力,并减少参数,我对embedding层和LSTM单元进行dropout。
4.建立深度为2的深度循环神经网络
为了提高模型的拟合能力,使用深度循环神经网络,我选择的深度为2。
5.给出二分类概率
对深度循环神经网络的最后节点的输出做逻辑回归,通过sigmoid使结果落到0-1之间,代表结果是正类的概率。
损失函数使用交叉熵,优化器选择Adam。
此部分代码如下(注:代码中装饰器的作用为划分命名空间以及保证张量运算只被定义一次):
# -*- coding: utf-8 -*-# @Time : 18-3-14 下午2:57# @Author : AaronJny# @Email : Aaron__7@163.comimporttensorflowastfimportfunctoolsimportsettingsHIDDEN_SIZE =128NUM_LAYERS =2defdoublewrap(function):@functools.wraps(function)defdecorator(*args, **kwargs):iflen(args) ==1andlen(kwargs) ==0andcallable(args[0]):returnfunction(args[0])else:returnlambdawrapee: function(wrapee, *args, **kwargs)returndecorator@doublewrapdefdefine_scope(function, scope=None, *args, **kwargs):attribute ='_cache_'+ function.__name__ name = scopeorfunction.__name__@[email protected](function)defdecorator(self):ifnothasattr(self, attribute):withtf.variable_scope(name, *args, **kwargs): setattr(self, attribute, function(self))returngetattr(self, attribute)returndecoratorclassModel(object):def__init__(self, data, lables, emb_keep, rnn_keep):"""
神经网络模型
:param data:数据
:param lables: 标签
:param emb_keep: emb层保留率
:param rnn_keep: rnn层保留率
"""self.data = data self.label = lables self.emb_keep = emb_keep self.rnn_keep = rnn_keep self.predict self.loss self.global_step self.ema self.optimize self.acc@define_scopedefpredict(self):"""
定义前向传播过程
:return:
"""# 词嵌入矩阵权重embedding = tf.get_variable('embedding', [settings.VOCAB_SIZE, HIDDEN_SIZE])# 使用dropout的LSTMlstm_cell = [tf.nn.rnn_cell.DropoutWrapper(tf.nn.rnn_cell.BasicLSTMCell(HIDDEN_SIZE), self.rnn_keep)for_inrange(NUM_LAYERS)]# 构建循环神经网络cell = tf.nn.rnn_cell.MultiRNNCell(lstm_cell)# 生成词嵌入矩阵,并进行dropoutinput = tf.nn.embedding_lookup(embedding, self.data) dropout_input = tf.nn.dropout(input, self.emb_keep)# 计算rnn的输出outputs, last_state = tf.nn.dynamic_rnn(cell, dropout_input, dtype=tf.float32)# 做二分类问题,这里只需要最后一个节点的输出last_output = outputs[:, -1, :]# 求最后节点输出的线性加权和weights = tf.Variable(tf.truncated_normal([HIDDEN_SIZE,1]), dtype=tf.float32, name='weights') bias = tf.Variable(0, dtype=tf.float32, name='bias') logits = tf.matmul(last_output, weights) + biasreturnlogits@define_scopedefema(self):"""
定义移动平均
:return:
"""ema = tf.train.ExponentialMovingAverage(settings.EMA_RATE, self.global_step)returnema@define_scopedefloss(self):"""
定义损失函数,这里使用交叉熵
:return:
"""loss = tf.nn.sigmoid_cross_entropy_with_logits(labels=self.label, logits=self.predict) loss = tf.reduce_mean(loss)returnloss@define_scopedefglobal_step(self):"""
step,没什么好说的,注意指定trainable=False
:return:
"""global_step = tf.Variable(0, trainable=False)returnglobal_step@define_scopedefoptimize(self):"""
定义反向传播过程
:return:
"""# 学习率衰减learn_rate = tf.train.exponential_decay(settings.LEARN_RATE, self.global_step, settings.LR_DECAY_STEP, settings.LR_DECAY)# 反向传播优化器optimizer = tf.train.AdamOptimizer(learn_rate).minimize(self.loss, global_step=self.global_step)# 移动平均操作ave_op = self.ema.apply(tf.trainable_variables())# 组合构成训练opwithtf.control_dependencies([optimizer, ave_op]): train_op = tf.no_op('train')returntrain_op@define_scopedefacc(self):"""
定义模型acc计算过程
:return:
"""# 对前向传播的结果求sigmoidoutput = tf.nn.sigmoid(self.predict)# 真负类ok0 = tf.logical_and(tf.less_equal(output,0.5), tf.equal(self.label,0))# 真正类ok1 = tf.logical_and(tf.greater(output,0.5), tf.equal(self.label,1))# 一个数组,所有预测正确的都为True,否则Falseok = tf.logical_or(ok0, ok1)# 先转化成浮点型,再通过求平均来计算accacc = tf.reduce_mean(tf.cast(ok, dtype=tf.float32))returnacc
三、组织数据集
我编写了一个类用于组织数据,方便训练和验证使用。代码很简单,就不多说了,直接贴代码:
# -*- coding: utf-8 -*-# @Time : 18-3-14 下午3:33# @Author : AaronJny# @Email : [email protected](object):def__init__(self, data_kind=0):"""
生成一个数据集对象
:param data_kind: 决定了使用哪种数据集 0-训练集 1-开发集 2-测试集
"""self.data, self.labels = self.read_data(data_kind) self.start =0# 记录当前batch位置self.data_size = len(self.data)# 样例数defread_data(self, data_kind):"""
从文件中加载数据
:param data_kind:数据集种类 0-训练集 1-开发集 2-测试集
:return:
"""# 获取数据集路径data_path = [settings.TRAIN_DATA, settings.DEV_DATA, settings.TEST_DATA][data_kind]# 加载data = np.load(data_path +'_data.npy') labels = np.load(data_path +'_labels.npy')returndata, labelsdefnext_batch(self, batch_size):"""
获取一个大小为batch_size的batch
:param batch_size: batch大小
:return:
"""start = self.start end = min(start + batch_size, self.data_size) self.start = end# 当遍历完成后回到起点ifself.start >= self.data_size: self.start =0# 返回一个batch的数据和标签returnself.data[start:end], self.labels[start:end]
四、模型训练
训练过程中,额外操作主要有两个:
1.使用移动平均
我使用移动平均的主要目的是使loss曲线尽量平滑,以及提升模型的泛化能力。
2.使用学习率指数衰减
目的是保证前期学习率足够大,能够快速降低loss,后期学习率变小,能更好地逼近最优解。
当然,就是说说而已,这次的训练数据比较简单,学习率衰减发挥的作用不大。
训练过程中,定期保存模型,以及checkpoint。这样可以在训练的同时,在验证脚本中读取最新模型进行验证。
此部分具体代码如下:
# -*- coding: utf-8
-*-# @Time : 18-3-14 下午4:41#
@Author : AaronJny# @Email :
importsettingsimporttensorflowastf
importmodelsimportdatasetimportosBATCH_SIZE = settings.BATCH_SIZE# 数据
x = tf.placeholder(tf.int32, [None,None])# 标签
y = tf.placeholder(tf.float32, [None,1])# emb层的dropout保留率
emb_keep = tf.placeholder(tf.float32)# rnn层的dropout保留率
rnn_keep = tf.placeholder(tf.float32)# 创建一个模型
model = models.Model(x, y, emb_keep, rnn_keep)# 创建数据集对象
data = dataset.Dataset(0)saver = tf.train.Saver()withtf.Session()assess:# 全局初始化sess.run(tf.global_variables_initializer())# 迭代训练forstepinrange(settings.TRAIN_TIMES):# 获取一个batch进行训练x, y = data.next_batch(BATCH_SIZE) loss, _ = sess.run([model.loss, model.optimize], {model.data: x, model.label: y, model.emb_keep: settings.EMB_KEEP_PROB, model.rnn_keep: settings.RNN_KEEP_PROB})# 输出lossifstep % settings.SHOW_STEP ==0:print'step {},loss is {}'.format(step, loss)# 保存模型ifstep % settings.SAVE_STEP ==0: saver.save(sess, os.path.join(settings.CKPT_PATH, settings.MODEL_NAME), model.global_step)
五、验证模型
加载最新模型进行验证,通过修改数据集对象的参数可以制定训练/开发/测试集进行验证。
加载模型的时候,使用移动平均的影子变量覆盖对应变量。
代码如下:
# -*- coding: utf-8 -*-# @Time : 18-3-14 下午5:09# @Author : AaronJny# @Email : Aaron__7@163.comimportsettingsimporttensorflowastfimportmodelsimportdatasetimportosimporttime# 为了在使用GPU训练的同时,使用CPU进行验证os.environ['CUDA_VISIBLE_DEVICES'] =''BATCH_SIZE = settings.BATCH_SIZE# 数据x = tf.placeholder(tf.int32, [None,None])# 标签y = tf.placeholder(tf.float32, [None,1])# emb层的dropout保留率emb_keep = tf.placeholder(tf.float32)# rnn层的dropout保留率rnn_keep = tf.placeholder(tf.float32)# 创建一个模型model = models.Model(x, y, emb_keep, rnn_keep)# 创建一个数据集对象data = dataset.Dataset(1)# 0-训练集 1-开发集 2-测试集# 移动平均变量restore_variables = model.ema.variables_to_restore()# 使用移动平均变量进行覆盖saver = tf.train.Saver(restore_variables)withtf.Session()assess:whileTrue:# 加载最新的模型ckpt = tf.train.get_checkpoint_state(settings.CKPT_PATH) saver.restore(sess, ckpt.model_checkpoint_path)# 计算并输出accacc = sess.run([model.acc], {model.data: data.data, model.label: data.labels, model.emb_keep:1.0, model.rnn_keep:1.0})print'acc is ', acc time.sleep(1)
六、对词汇表进行操作的几个方法
把对词汇表进行操作的几个方法提取出来了,放到了utils.py文件中。
# -*- coding: utf-8 -*-# @Time : 18-3-14 下午2:44# @Author : AaronJny# @Email : [email protected]_vocab_list():"""
读取词汇表
:return:由词汇表中所有单词组成的列表
"""withopen(settings.VOCAB_PATH,'r')asf: vocab_list = f.read().strip().split('\n')returnvocab_listdefread_word_to_id_dict():"""
生成一个单词到编号的映射
:return:单词到编号的字典
"""vocab_list = read_vocab_list() word2id = dict(zip(vocab_list, range(len(vocab_list))))returnword2iddefread_id_to_word_dict():"""
生成一个编号到单词的映射
:return:编号到单词的字典
"""vocab_list = read_vocab_list() id2word = dict(zip(range(len(vocab_list)), vocab_list))returnid2worddefget_id_by_word(word, word2id):"""
给定一个单词和字典,获得单词在字典中的编号
:param word: 给定单词
:param word2id: 单词到编号的映射
:return: 若单词在字典中,返回对应的编号 否则,返回word2id['']
"""ifwordinword2id:returnword2id[word]else:returnword2id['']
七、对模型进行配置
模型的配置参数大多数都被提取出来,单独放到了settings.py文件中,可以在这里对模型进行配置。
# -*- coding: utf-8 -*-# @Time :
18-3-14 下午2:44# @Author :
AaronJny# @Email : [email protected]
# 源数据路径
ORIGIN_NEG ='data/rt-polarity.neg'
ORIGIN_POS ='data/rt-polarity.pos'# 转码后的数据路径
NEG_TXT ='data/neg.txt'
POS_TXT ='data/pos.txt'# 词汇表路径
VOCAB_PATH ='data/vocab.txt'# 词向量路径
NEG_VEC ='data/neg.vec'
POS_VEC ='data/pos.vec'# 训练集路径
TRAIN_DATA ='data/train'# 开发集路径
DEV_DATA ='data/dev'# 测试集路径
TEST_DATA ='data/test'# 模型保存路径
CKPT_PATH ='ckpt'# 模型名称
MODEL_NAME ='model'# 词汇表大小
VOCAB_SIZE =10000# 初始学习率
LEARN_RATE =0.0001# 学习率衰减
LR_DECAY =0.99# 衰减频率
LR_DECAY_STEP =1000# 总训练次数
TRAIN_TIMES =2000# 显示训练loss的频率
SHOW_STEP =10# 保存训练模型的频率
SAVE_STEP =100# 训练集占比
TRAIN_RATE =0.8# 开发集占比
DEV_RATE =0.1# 测试集占比
TEST_RATE =0.1# BATCH大小
BATCH_SIZE =64# emb层dropout保留率
EMB_KEEP_PROB =0.5# rnn层dropout保留率
RNN_KEEP_PROB =0.5# 移动平均衰减率
EMA_RATE =0.99
八、运行模型
至此,模型构建完成。模型的运行步骤大致如下:
确保数据文件放在了对应路径中,运行python process_data对数据进行预处理。
运行python train.py对模型进行训练,训练好的模型会自动保存到对应的路径中。
运行python eval.py读取保存的最新模型,对训练/开发/测试集进行验证。
我简单跑了一下,由于数据集较小,模型的泛化能力不是很好。
当训练集、开发集、测试集的分布为[0.8,0.1,0.1],训练2000个batch_size=64的mini_batch时,模型在各数据集上的acc表现大致如下:
训练集 0.95
开发集 0.79
测试集 0.80
更多
转行做机器学习,要学的还很多,文中如有错误纰漏之处,恳请诸位大佬拍砖指教…
项目GitHub地址:https://github.com/AaronJny/emotional_classification_with_rnn
— End —