2017年2月16日,Google正式对外发布Google TensorFlow 1.0版本,并保证本次的发布版本API接口完全满足生产环境稳定性要求。这是TensorFlow的一个重要里程碑,标志着它可以正式在生产环境放心使用。在国内,从InfoQ的判断来看,TensorFlow仍处于创新传播曲线的创新者使用阶段,大部分人对于TensorFlow还缺乏了解,社区也缺少帮助落地和使用的中文资料。InfoQ期望通过深入浅出TensorFlow系列文章能够推动Tensorflow在国内的发展。欢迎加入QQ群(群号:183248479)深入讨论和交流。下面为本系列的前四篇文章:
\\深入浅出Tensorflow(一):深度学习及TensorFlow简介
\\深入浅出TensorFlow(二):TensorFlow解决MNIST问题入门
\\深入浅出Tensorflow(三):训练神经网络模型的常用方法
\\ \\循环神经网络(recurrent neural network,RNN)源自于1982年由Saratha Sathasivam提出的霍普菲尔德网络。霍普菲尔德网络因为实现困难,在其提出的时候并且没有被合适地应用。该网络结构也于1986年后被全连接神经网络以及一些传统的机器学习算法所取代。然而,传统的机器学习算法非常依赖于人工提取的特征,使得基于传统机器学习的图像识别、语音识别以及自然语言处理等问题存在特征提取的瓶颈。而基于全连接神经网络的方法也存在参数太多、无法利用数据中时间序列信息等问题。随着更加有效的循环神经网络结构被不断提出,循环神经网络挖掘数据中的时序信息以及语义信息的深度表达能力被充分利用,并在语音识别、语言模型、机器翻译以及时序分析等方面实现了突破。
\\循环神经网络的主要用途是处理和预测序列数据。在之前介绍的全连接神经网络或卷积神经网络模型中,网络结构都是从输入层到隐含层再到输出层,层与层之间是全连接或部分连接的,但每层之间的节点是无连接的。考虑这样一个问题,如果要预测句子的下一个单词是什么,一般需要用到当前单词以及前面的单词,因为句子中前后单词并不是独立的。比如,当前单词是“很”,前一个单词是“天空”,那么下一个单词很大概率是“蓝”。循环神经网络的来源就是为了刻画一个序列当前的输出与之前信息的关系。从网络结构上,循环神经网络会记忆之前的信息,并利用之前的信息影响后面结点的输出。也就是说,循环神经网络的隐藏层之间的结点是有连接的,隐藏层的输入不仅包括输入层的输出,还包括上一时刻隐藏层的输出。
\\图1展示了一个典型的循环神经网络。对于循环神经网络,一个非常重要的概念就是时刻。循环神经网络会对于每一个时刻的输入结合当前模型的状态给出一个输出。从图1中可以看到,循环神经网络的主体结构A的输入除了来自输入层Xt,还有一个循环的边来提供当前时刻的状态。在每一个时刻,循环神经网络的模块A会读取t时刻的输入Xt,并输出一个值ht。同时A的状态会从当前步传递到下一步。因此,循环神经网络理论上可以被看作是同一神经网络结构被无限复制的结果。但出于优化的考虑,目前循环神经网络无法做到真正的无限循环,所以,现实中一般会将循环体展开,于是可以得到图2所展示的结构。
\\ \\图1 循环神经网络经典结构示意图
\\在图2中可以更加清楚的看到循环神经网络在每一个时刻会有一个输入Xt,然后根据循环神经网络当前的状态At提供一个输出Ht。从而神经网络当前状态At是根据上一时刻的状态At-1和当前输入Xt共同决定的。从循环神经网络的结构特征可以很容易地得出它最擅长解决的问题是与时间序列相关的。循环神经网络也是处理这类问题时最自然的神经网络结构。对于一个序列数据,可以将这个序列上不同时刻的数据依次传入循环神经网络的输入层,而输出可以是对序列中下一个时刻的预测。循环神经网络要求每一个时刻都有一个输入,但是不一定每个时刻都需要有输出。在过去几年中,循环神经网络已经被广泛地应用在语音识别、语言模型、机器翻译以及时序分析等问题上,并取得了巨大的成功。
\\(点击放大图像)
\\ \\图2 循环神经网络按时间展开后的结构
\\以机器翻译为例来介绍循环神经网络是如何解决实际问题的。循环神经网络中每一个时刻的输入为需要翻译的句子中的单词。如图3所示,需要翻译的句子为ABCD,那么循环神经网络第一段每一个时刻的输入就分别是A、B、C和D,然后用“”作为待翻译句子的结束符。在第一段中,循环神经网络没有输出。从结束符“”开始,循环神经网络进入翻译阶段。该阶段中每一个时刻的输入是上一个时刻的输出,而最终得到的输出就是句子ABCD翻译的结果。从图8-3中可以看到句子ABCD对应的翻译结果就是XYZ,而Q是代表翻译结束的结束符。
\\(点击放大图像)
\\ \\如之前所介绍,循环神经网络可以被看做是同一神经网络结构在时间序列上被复制多次的结果,这个被复制多次的结构被称之为循环体。如何设计循环体的网络结构是循环神经网络解决实际问题的关键。和卷积神经网络过滤器中参数是共享的类似,在循环神经网络中,循环体网络结构中的参数在不同时刻也是共享的。
\\图4展示了一个使用最简单的循环体结构的循环神经网络,在这个循环体中只使用了一个类似全连接层的神经网络结构。下面将通过图4中所展示的神经网络来介绍循环神经网络前向传播的完整流程。循环神经网络中的状态是通过一个向量来表示的,这个向量的维度也称为循环神经网络隐藏层的大小,假设其为h。从图4中可以看出,循环体中的神经网络的输入有两部分,一部分为上一时刻的状态,另一部分为当前时刻的输入样本。对于时间序列数据来说(比如不同时刻商品的销量),每一时刻的输入样例可以是当前时刻的数值(比如销量值);对于语言模型来说,输入样例可以是当前单词对应的单词向量(word embedding)。
\\(点击放大图像)
\\ \\图4 使用单层全连接神经网络作为循环体的循环神经网络结构图(图中中间标有tanh的小方框表示一个使用了tanh作为激活函数的全连接神经网络)
\\长短时记忆网络(LTSM)结构
\\循环神经网络工作的关键点就是使用历史的信息来帮组当前的决策。例如使用之前出现的单词来加强对当前文字的理解。循环神经网络可以更好地利用传统神经网络结构所不能建模的信息,但同时,这也带来了更大的技术挑战——长期依赖(long-term dependencies)问题。
\\在有些问题中,模型仅仅需要短期内的信息来执行当前的任务。比如预测短语“大海的颜色是蓝色”中的最后一个单词“蓝色”时,模型并不需要记忆这个短语之前更长的上下文信息——因为这一句话已经包含了足够的信息来预测最后一个词。在这样的场景中,相关的信息和待预测的词的位置之间的间隔很小,循环神经网络可以比较容易地利用先前信息。
\\但同样也会有一些上下文场景更加复杂的情况。比如当模型试着去预测段落“某地开设了大量工厂,空气污染十分严重... 这里的天空都是灰色的”的最后一个单词时,仅仅根据短期依赖就无法很好的解决这种问题。因为只根据最后一小段,最后一个词可以是“蓝色的”或者“灰色的”。但如果模型需要预测清楚具体是什么颜色,就需要考虑先前提到但离当前位置较远的上下文信息。因此,当前预测位置和相关信息之间的文本间隔就有可能变得很大。当这个间隔不断增大时,类似图4中给出的简单循环神经网络有可能会丧失学习到距离如此远的信息的能力。或者在复杂语言场景中,有用信息的间隔有大有小、长短不一,循环神经网络的性能也会受到限制。
\\长短时记忆网络(long short term memory, LSTM)的设计就是为了解决这个问题,而循环神经网络被成功应用的关键就是LSTM。在很多的任务上,采用LSTM结构的循环神经网络比标准的循环神经网络表现更好。在下文中将重点介绍LSTM结构。LSTM结构是由Sepp Hochreiter和Jürgen Schmidhuber于1997年提出的,它是一种特殊的循环体结构。如图5所示,与单一tanh循环体结构不同,LSTM是一种拥有三个“门”结构的特殊网络结构。
\\(点击放大图像)
\\ \\图5 LSTM单元结构示意图
\\LSTM靠一些“门”的结构让信息有选择性地影响每个时刻循环神经网络中的状态。所谓“门”的结构就是一个使用sigmoid神经网络和一个按位做乘法的操作,这两个操作合在一起就是一个“门”的结构。之所以该结构叫做“门”是因为使用sigmoid作为激活函数的全连接神经网络层会输出一个0到1之间的数值,描述当前输入有多少信息量可以通过这个结构。于是这个结构的功能就类似于一扇门,当门打开时(sigmoid神经网络层输出为1时),全部信息都可以通过;当门关上时(sigmoid神经网络层输出为0时),任何信息都无法通过。本节下面的篇幅将介绍每一个“门”是如何工作的。
\\为了使循环神经网更有效的保存长期记忆,图5中“遗忘门”和“输入门”至关重要,它们是LSTM结构的核心。“遗忘门”的作用是让循环神经网络“忘记”之前没有用的信息。比如一段文章中先介绍了某地原来是绿水蓝天,但后来被污染了。于是在看到被污染了之后,循环神经网络应该“忘记”之前绿水蓝天的状态。这个工作是通过“遗忘门”来完成的。“遗忘门”会根据当前的输入xt、上一时刻状态ct-1和上一时刻输出ht-1共同决定哪一部分记忆需要被遗忘。在循环神经网络“忘记”了部分之前的状态后,它还需要从当前的输入补充最新的记忆。这个过程就是“输入门”完成的。如图5所示,“输入门”会根据xt、ct-1和ht-1决定哪些部分将进入当前时刻的状态ct。比如当看到文章中提到环境被污染之后,模型需要将这个信息写入新的状态。通过“遗忘门”和“输入门”,LSTM结构可以更加有效的决定哪些信息应该被遗忘,哪些信息应该得到保留。
\\LSTM结构在计算得到新的状态ct后需要产生当前时刻的输出,这个过程是通过“输出门”完成的。“输出们”会根据最新的状态ct、上一时刻的输出ht-1和当前的输入xt来决定该时刻的输出ht。比如当前的状态为被污染,那么“天空的颜色”后面的单词很可能就是“灰色的”。
\\相比图4中展示的循环神经网络,使用LSTM结构的循环神经网络的前向传播是一个相对比较复杂的过程。具体LSTM每个“门”中的公式可以参考论文Long short-term memory。在TensorFlow中,LSTM结构可以被很简单地实现。以下代码展示了在TensorFlow中实现使用LSTM结构的循环神经网络的前向传播过程。
\\\ #定义一个LSTM结构。在TensorFlow中通过一句简单的命令就可以实现一个完整LSTM结构。\# LSTM中使用的变量也会在该函数中自动被声明。\lstm = rnn_cell.BasicLSTMCell(lstm_hidden_size)\\# 将LSTM中的状态初始化为全0数组。和其他神经网络类似,在优化循环神经网络时,每次也\# 会使用一个batch的训练样本。以下代码中,batch_size给出了一个batch的大小。\# BasicLSTMCell类提供了zero_state函数来生成全领的初始状态。\state = lstm.zero_state(batch_size, tf.float32)\\# 定义损失函数。\loss = 0.0\# 在8.1节中介绍过,虽然理论上循环神经网络可以处理任意长度的序列,但是在训练时为了\# 避免梯度消散的问题,会规定一个最大的序列长度。在以下代码中,用num_steps\# 来表示这个长度。\for i in range(num_steps):\# 在第一个时刻声明LSTM结构中使用的变量,在之后的时刻都需要复用之前定义好的变量。 \if i \u0026gt; 0: tf.get_variable_scope().reuse_variables()\\ # 每一步处理时间序列中的一个时刻。将当前输入(current_input)和前一时刻状态\ # (state)传入定义的LSTM结构可以得到当前LSTM结构的输出lstm_output和更新后\ # 的状态state。\ lstm_output, state = lstm(current_input, state)\ # 将当前时刻LSTM结构的输出传入一个全连接层得到最后的输出。\ final_output = fully_connected(lstm_output)\ # 计算当前时刻输出的损失。\ loss += calc_loss(final_output, expected_output)\
\\
通过上面这段代码看到,通过TensorFlow可以非常方便地实现使用LSTM结构的循环神经网络,而且并不需要用户对LSTM内部结构有深入的了解。
\\自然语言建模
\\简单地说,语言模型的目的是为了计算一个句子的出现概率。在这里把句子看成是单词的序列,于是语言模型需要计算的就是p(w1,w2,w3,…,wn)。利用语言模型,可以确定哪个单词序列的可能性更大,或者给定若干个单词,可以预测下一个最可能出现的词语。举个音字转换的例子,假设输入的拼音串为“xianzaiquna”,它的输出可以是“西安在去哪”,也可以是“现在去哪”。根据语言常识,我们知道转换成第二个的概率更高。语言模型就可以告诉我们后者的概率大于前者,因此在大多数情况下转换成后者比较合理。
\\语言模型效果好坏的常用评价指标是复杂度(perplexity)。简单来说,perplexity值刻画的就是通过某一个语言模型估计的一句话出现的概率。比如当已经知道(w1,w2,w3···wm)这句话出现在语料库之中,那么通过语言模型计算得到的这句话的概率越高越好,也就是perplexity值越小越好。计算perplexity值的公式如下:
\\(点击放大图像)
\\ \\复杂度perplexity表示的概念其实是平均分支系数(average branch factor),即模型预测下一个词时的平均可选择数量。例如,考虑一个由0~9这10个数字随机组成的长度为m的序列。由于这10个数字出现的概率是随机的,所以每个数字出现的概率是1/10。因此,在任意时刻,模型都有10个等概率的候选答案可以选择,于是perplexity就是10(有10个合理的答案)。perplexity的计算过程如下:
\\(点击放大图像)
\\ \\因此,如果一个语言模型的perplexity是89,就表示,平均情况下,模型预测下一个词时,有89个词等可能地可以作为下一个词的合理选择。
\\PTB (Penn Treebank Dataset)文本数据集是语言模型学习中目前最被广泛使用数据集。本小节将在PTB数据集上使用循环神经网络实现语言模型。在给出语言模型代码之前将先简单介绍PTB数据集的格式以及TensorFlow对于PTB数据集的支持。首先,需要下载来源于Tomas Mikolov网站上的PTB数据。数据的下载地址为:
\\http://www.fit.vutbr.cz/~imikolov/rnnlm/simple-examples.tgz
\\将下载下来的文件解压之后可以得到如下文件夹列表
\\\1-train/ \2-nbest-rescore/ \3-combination/ \4-data-generation/ \5-one-iter/ \6-recovery-during-training/ \7-dynamic-evaluation/ \8-direct/ \9-char-based-lm/ \data/ \models/ \rnnlm-0.2b/ \
\\
在本文中只需要关心data文件夹下的数据,对于其他文件不再一一介绍,感兴趣的读者可以自行参考README文件。在data文件夹下总共有7个文件,但本文中将只会用到以下三个文件:
\\\ptb.test.txt #测试集数据文件\ptb.train.txt #训练集数据文件\ptb.valid.txt #验证集数据文件\
\\
这三个数据文件中的数据已经经过了预处理,包含了10000 个不同的词语和语句结束标记符(在文本中就是换行符)以及标记稀有词语的特殊符号。下面展示了训练数据中的一行:
\\\mr. \u0026lt;unk\u0026gt; is chairman of \u0026lt;unk\u0026gt; n.v. the dutch publishing group\
\\
为了让使用PTB数据集更加方便,TensorFlow提供了两个函数来帮助实现数据的预处理。首先,TensorFlow提供了ptb_raw_data函数来读取PTB的原始数据,并将原始数据中的单词转化为单词ID。以下代码展示了如何使用这个函数。
\\\from tensorflow.models.rnn.ptb import reader\# 存放原始数据的路径。\DATA_PATH = \"/path/to/ptb/data\"\train_data, valid_data, test_data, _ = reader.ptb_raw_data(DATA_PATH) \# 读取数据原始数据。\print len(train_data)\print train_data[:100]\'''\
\\
运行以上程序可以得到输出:
\\\929589 \[9970, 9971, 9972, 9974, 9975, 9976, 9980, 9981, 9982, 9983, 9984, 9986, 9987, 9988, 9989, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999, 2, 9256, 1, 3, 72, 393, 33, 2133, 0, 146, 19, 6, 9207, 276, 407, 3, 2, 23, 1, 13, 141, 4, 1, 5465, 0, 3081, 1596, 96, 2, 7682, 1, 3, 72, 393, 8, 337, 141, 4, 2477, 657, 2170, 955, 24, 521, 6, 9207, 276, 4, 39, 303, 438, 3684, 2, 6, 942, 4, 3150, 496, 263, 5, 138, 6092, 4241, 6036, 30, 988, 6, 241, 760, 4, 1015, 2786, 211, 6, 96, 4]\'''\
\\
从输出中可以看出训练数据中总共包含了929589 个单词,而这些单词被组成了一个非常长的序列。这个序列通过特殊的标识符给出了每句话结束的位置。在这个数据集中,句子结束的标识符ID为2。
\\虽然循环神经网络可以接受任意长度的序列,但是在训练时需要将序列按照某个固定的长度来截断。为了实现截断并将数据组织成batch,TensorFlow提供了ptb_iterator函数。以下代码展示了如何使用ptb_iterator函数。
\\\from tensorflow.models.rnn.ptb import reader\\# 类似地读取数据原始数据。\DATA_PATH = \"/path/to/ptb/data\"\train_data, valid_data, test_data, _ = reader.ptb_raw_data(DATA_PATH) \\# 将训练数据组织成batch大小为4、截断长度为5的数据组。\result = reader.ptb_iterator(train_data, 4, 5)\# 读取第一个batch中的数据,其中包括每个时刻的输入和对应的正确输出。\x, y = result.next()\print \"X:\