Tensorflow下Char-RNN项目代码详解

前言

Char-RNN,字符级循环神经网络,出自于Andrej Karpathy写的The Unreasonable Effectiveness of Recurrent Neural Networks。众所周知,RNN非常擅长处理序列问题。序列数据前后有很强的关联性,而RNN通过每个单元权重与偏置的共享以及循环计算(前面处理过的信息会被利用处理后续信息)来体现。Char-RNN模型是从字符的维度上,让机器生成文本,即通过已经观测到的字符出发,预测下一个字符出现的概率,也就是序列数据的推测。现在网上介绍的用深度学习写歌、写诗、写小说的大多都是基于这个方法。

在基本的RNN单元中,只有一个隐藏状态,对于长距离的记忆效果很差(序列开始的信息在后期保留很少),而且存在梯度消失的问题,因此诞生了许多变体,如LSTM、GRU等。本文介绍的Char-RNN就是选用LSTM作为基本模型。关于LSTM的讲解可以看这篇文章:http://colah.github.io/posts/2015-08-Understanding-LSTMs,讲的很清楚。

本文定位tensorflow框架初学者以及深度学习基础一般的读者,尽量详细地解读程序中使用到的每一句代码。

本文中代码显示不下的部分,右滑即可浏览。

项目代码出处

V1.0:github.com/karpathy/char-rnn

V2.0:github.com/NELSONZHAO/zhihu/tree/master/anna_lstm

V3.0:github.com/hzy46/Char-RNN-TensorFlow

这几个项目都是关于Char-RNN在tensorflow下的实现:1.0版本是Char-RNN的模型作者给出的代码,但是是用lua基于torch写的;2.0版本是在tensorflow下的实现,通过构建LSTM模型完成了对《安娜卡列宁娜》文本的学习并基于学习成果生成了新的文本;3.0版本在此基础上进行改动,增加了embdding层,实现对中文的学习与支持。本文的讲解主要是引用3.0版本的代码,尽量讲清楚每个操作细节以及调用函数的功能与用法。

运行环境:Ubuntu16.04+python3

总览

代码共分为四个模块:

/ model.py / read_utils.py /train.py /sample.py /

model.py包含了CharRNN整个框架的构建(CharRNN类),主要是定义输入层、rnn层、softmax层,构建损失函数、构建优化器,构建训练函数、样本生成函数、恢复点载入函数。

read_utils.py的内容是batch_generator函数(生成每批次喂入网络训练的样本)以及TextConverter类(主要用于将文本转化成数值存入矩阵,或将数值转回文本)。

train.py以及sample.py主要是调用read_utils.py中batch_generator以及TextConverter进行初始化,再调用model.py中的构建训练函数、样本生成函数,和一些关于文件存储等操作。

model.py

pick_top_n函数的功能是从概率最大的前n个字符中,根据概率分布随机挑选一个字符作为下一个字符。

参数说明:preds是预测各字符在下一次出现的概率序列,vocab_size是每个字符占位的大小,top_n表示从前n个概率最大的字符中挑选(为了避免生成那些小概率的字符,让语言在随机的基础上更符合逻辑)。

构建CharRNN类,__init__是构造函数,创建该类对象时会首先运行该函数。

参数说明(首先要搞清楚这些参数的含义,有助于接下去阅读代码):self表示对象自身(创建对象时可以忽略);num_classes表示共有多少种字符;num_seqs表示每个batch中序列的个数;num_steps表示单个序列的长度;lstm_size表示lstm隐藏层规模;num_layers表示需要的lstm层数;learning_rate表示学习率,在优化器上使用;grad_clip表示修剪比例,用于梯度裁剪,解决梯度爆炸问题;sampling表示是否是sample生成模式,True表示是;train_keep_prob表示dropout的比例,解决过拟合问题;use_embedding表示是否需要embedding层,中文等字符较多,需要embedding,否则矩阵过于稀疏;embedding_size表示embedding层的规模。

创建输入层。用占位符先把需要的变量inputs(输入),targets(输出),keep_prob(dropout比例),tf.placeholder有三个参数,类型、大小(没有则是表示单个数值)、名称,后面使用时通过feed_dict传入相应的值。tf.one_hot(indices,depth,on_value=None,off_value=None,dtype=None,name=None),表示将输入转化成one-hot矩阵形式,indices表示输入,depth表示one-hot的维度。one-hot其实就是先将字符编码,用某一位是1其余是0的形式,比如“ojbk”如果转换成one-hot形式,就是o:[1,0,0,0]; j:[0,1,0,0]; b:[0,0,1,0]; k:[0,0,0,1]。英文字符加上所有符号,一共也没有多少;而中文字符很多,使用one-hot会使得矩阵非常稀疏(存在许多0),不利于后续处理。embedding层解决的就是这个问题,因为embedding表示的向量中允许出现多个1,达到降维的效果。用get_variable创建一个变量,第一个参数是名称,第二个参数是其矩阵的尺寸(字符总个数×embedding尺寸);tf.nn.embedding_lookup其实就是有点字典索引的意思(lookup就是查字典嘛),将inputs根据embedding这个字典矩阵转化为新的编码矩阵。这里的lstm_inputs是三维的:[num_seqs, num_steps, embedding_size]。另外,with tf.device("/cpu:0")表示使用第一个cpu进行运算。

创建lstm层,首先定义get_a_cell函数用于创建单个lstm的cell单元,然后通过循环调用堆叠形成循环神经网络。get_a_cell有两个参数,lstm_size表示其隐藏层规模,keep_prob表示dropout比例,之前都有讲过。tf.nn.rnn_cell.BasicLSTMCell用于创建lstm的cell(此外,还有rnn_cell.GRUCell以及rnn_cell.BasicRNNCell等,就是每个cell中的结构不同,lstm的cell结构如下图),tf.nn.rnn_cell.DropoutWrapper(cell, input_keep_prob=1.0, output_keep_prob=1.0)是dropout操作防止过拟合,RNN的dropout并不会在同一层cell中使用,只会在层与层之间使用,这个与CNN有很大不同,所以这里input_keep_prob是输入数据使用dropout的比例,默认1.0即不执行,输出同理。返回的drop是操作后的结果。

lstm_outputs存放的就是循环计算后,各个位置的状态,用 tf.concat(用于连接多个矩阵)将lstm_outputs每一步的结果相连接,第二个参数1表示是列连接,若是0则表示行连接。 tf.reshape用于矩阵尺寸的变换,第二个参数表示希望变换得到的尺寸大小,允许存在一个-1,这表示通过其它维度自动计算这个-1指代的数值。

创建优化器,tf.trainable_variables()会返回所有可训练的参数,使用adam优化算法进行优化,同时通过clipping gradients算法解决梯度爆炸的问题。tf.clip_by_global_norm是clipping gradients操作,对tvars(具体是每个单一变量)关于loss求梯度时,对那些梯度数值超过了梯度全局范数的进行缩小操作,全局范数定义是所有梯度的平方和的平方根。其返回值grads是修剪后的梯度。tf.train.AdamOptimizer是定义一个Adam优化器,参数是指其学习率,也是梯度下降的思想。train_op.apply_gradients是在优化器中应用梯度修建。zip表示将多个序列合并成元组。

到这里,一个Char-RNN所需要的砖头就全部构建完毕了。后面是应用这些砖头的过程。

训练函数,后面train.py主要就是调用这个函数对网络进行训练。

参数说明:batch_generator表示生成的batch(read_utils.py中会构建),max_steps表示最大训练的步骤,save_path表示保存路径,save_every_n表示每n步保存一下模型,log_every_n表示每n步进行一次记录。

tf.Session构建一个计算图。with self.session as sess表示后面将self.session用sess来简化替代。每一次要通过计算图进行计算的内容都通过sess.run()来传递。tf.global_variables_initializer()表示从计算图中初始化所有TensorFlow变量。启动计算图,final_state、loss是通过前面build_lstm层于build_loss计算得到的,运行optimizer优化器进行优化。最终记录batch_loss,使用final_state来替代new_state,new_state又作为下一个feed,从而形成循环。下图就是训练过程中输出的信息。

样本生成函数,后面sample.py主要就是调用这个函数生成文本。

参数说明:n_samples表示生成样本的长度,prime用于存放一段文本给接下来生成的样本起个头,vocab_size表示每个字符占据的大小。

初始化后,启动计算图,proba_prediction是softmax层归一化后的概率结果,得到final_state替代new_state。根据得到的概率结果,使用之前定义的pick_top_n函数,生成下一位字符,再将这个新的字符与new_state作为下一个feed,不断生成新的字符。这里keep_prob是1表示这里不使用dropout。新生成的字符通过list的append方法不断增加到samples中。第一段是根据起头的prime来生成字符,第二段是用循环i次继续生成指定数目的字符。这里值得一提的是,第一段使用for c in prime,也就是我prime里的每一个都进一次循环,其实后面生成的字符是跟前面一一对应的。比如我用“XYZ”来作为开头,后面生成了“ABC”,这里A其实只是对应了X生成的,B是对应了Y生成的,Z是对应了C生成的,C后面再生成的才是继续延后下去的。最后通过return np.array,返回整个samples。

载入函数,每次sample运行前,通过它载入训练的模型,checkpoint文件是saver类在训练时保存的中间结果,第二个参数checkpoint表示checkpoint文件的路径。

read_utils.py

batch生成函数,包括inputs与targets。copy.copy是一种对象复制方法,也叫浅复制,修改子对象会对其造成影响;copy.deepcopy与之相对应,深复制,修改子对象不造成影响。计算出batch_size(每个batch的尺寸)以及n_batches(共有多少个batch)后,将余下的部分去除,并改变其尺寸适应输入。np.random.shuffle是打乱矩阵内部元素的操作。每n_steps进一次循环(即生成一个batch),x就是提取出相应那一段的内容,np.zeros_like用于生成大小一致的tensor但所有元素全为0,然后将x的第一个元素放到y最后,其他位元素位置往前顺移一位赋给y。这x就代表了输入,而y就是有监督训练的标签(每个字符做预测时的正确答案就是文本的下一个字符)。yield的使用是将函数作为生成器,这样做省内存。

这里定义了TextConverter类,部分字符与数值之间的转化操作以及相关属性的定义都放在这里。

构造函数中的参数说明:text表示待转化的文本,直接通过参数的形式传入;max_vocab表示字符的最大容量;filename表示带转化的文档。这里可以使用text,也可以使用filename,但总需要有一个对象转换。

使用filename,也就是对文档进行操作。open(filename, 'rb'),这里rb表示以二进制的形式读取文档。pickle.load是将文档中的数据解析为一个python对象。

使用text时,首先用set方法对text去重,存在vocab中;然后统计每个字符出现个次数,存在vocab_count中;通过计数生成字符与次数对应的list存在vocab_count_list中;使用sort方法对其按次序进行排序,这里使用了lambda表达式(这只是一种简化代码的小操作)。如果字符的总个数大于了之前定义的max_vocab,则把超出的部分除去。经过这些操作后,重新生成vocab。

word_to_int_table是将文本生成一个对应索引表。enumerate方法用于生成对象的索引序列,比如vocab中是(“我”,“们”),则生成[(0,“我”),(1,“们”)],这里还将序号与对应内容作了颠倒。int_to_word_table是将文本转为字典的存储方式,比如{“我”:0 , “们”:1}。

vocab_size,返回字符的数量+1;word_to_int,如果字符有索引则返回索引,没有则返回最后那个数值(vocab_size中+1的作用就体现在这里);int_to_word,如果索引是最后那个数值,则返回表示unknown,其他索引则返回相应字符,另外超出范围的索引则抛出异常;text_to_arr,用于一段文字逐一转化为索引后生成矩阵;arr_to_text,刚才的反向操作,"".join是结合字符串的用法。save_to_file,保存操作,通过pickle.dump将python对象保存到文档中去。

train.py

一通定义操作,tf.flags.DEFINE_string定义一些全局参数,但是这句代码在spyder编译器下只能运行一次,运行第二次会报错,必须关掉重开才能运行,不知道为什么。这些参数是需要根据不同的训练任务进行调整的,也就是经常说到的“调参”。

训练的主函数。 os.path.join生成model的路径名,如果model这个文件夹不存在,则创建一个文件夹;读取文件后,创建一个converter对象(TextConverter类),arr储存文本转数值的矩阵,g用来存储batch(包括训练输入x及标签y)。创建一个model对象(CharRNN类),并进行训练。tf.app.run()表示运行程序后执行main函数。

sample.py

跟train.py一样,也是一通定义。

第一行代码运行报错,被我注释掉了,可能的原因应该是如果我当时没有给samples起头,这句话就找不到对象。

首先也是创建了一个converter对象;然后找到checkpoint,即之前训练好的模型文件;创建model对象,载入这个checkpoint;将start从字符串转为数值矩阵,通过sample方法开始生成;最后将结果转化为文本打印出来。

后记

这是我第一次写这种解释代码的文章,一份代码需要前前后后翻阅多次才能将局部联系起来,所以有些地方我也重复叙述了多次,可能很多地方过于啰嗦,很多地方也没表达的很清楚,另外也会存在部分代码理解有误,希望大家可以批评指正。如果仔细读了以上的代码,你应该能从小细节中发现我拿这个网络做了点什么(不可描述),效果还可以我就不展示了,如果好奇可以私聊我。文章比较长,看到最后的读者都不容易,感谢支持。

本文来自企鹅号 - JRunning媒体

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏null的专栏

TensorFlow入门——Softmax Regression

下面的代码是利用TensorFlow实现的Softmax Regression的基本过程: ''' @author:zhaozhiyong @date:2017...

2795
来自专栏人工智能LeadAI

译文 | 与TensorFlow的第一次接触 第三章:聚类

前一章节中介绍的线性回归是一种监督学习算法,我们使用数据与输出值(标签)来建立模型拟合它们。但是我们并不总是有已经打标签的数据,却仍然想去分析它们。这种情况下,...

4316
来自专栏数据结构与算法

拉格朗日插值

存在性和唯一性的证明以后再补。。。。 拉格朗日插值 拉格朗日插值,emmmm,名字挺高端的:joy: 它有什么应用呢? 我们在FFT中讲到过 设n-1次多项式为...

2897
来自专栏小鹏的专栏

02 The TensorFlow Way(1)

The TensorFlow Way Introduction:          现在我们介绍了TensorFlow如何创建张量,使用变量和占位符,我们将介...

21210
来自专栏数据结构与算法

02:奇数单增序列 个人博客doubleq.win

 个人博客doubleq.win 02:奇数单增序列 查看 提交 统计 提问 总时间限制: 1000ms 内存限制: 65536kB描述 给定一个长度为N(不...

3388
来自专栏bboysoul

1167: C语言实验题――分数序列

描述:有一个分数序列:2/1, 3/2, 5/3, 8/5, 13/8, …编写程序求出这个序列的前n项之和。 输入:输入只有一个正整数n,1≤n≤10。 ...

963
来自专栏WeaponZhi

使用Octave来学习Machine Learning(二)

前言 上一篇我们介绍了 Octave 的一些基本情况,大家对 Octave 应该已经有了一个基本的了解,我相信看这篇文章的朋友已经在自己的电脑中安装好 Ocat...

3546
来自专栏拂晓风起

妙用Pixel bender执行复杂运算/普通数据运算 传递Vector数组

1052
来自专栏潇涧技术专栏

Problem: Longest Common Subsequence

最长公共子序列(LCS)是典型的动态规划问题,如果不理解动态规划请移步先看这篇动态规划的总结,否则本文中的代码实现会不理解的哟!

581
来自专栏数据结构与算法

洛谷P2503 [HAOI2006]均分数据(模拟退火)

1880

扫码关注云+社区

领取腾讯云代金券