TensorFlow8:循环神经网络

在前面我们知道了卷积神经网络的网络结构,并介绍了如何使用卷积神经网络解决图像识别问题。本章中将介绍另外一种常用的神经网络结构——循环神经网络(recurrent neural network RNN)以及循环神经网络中的一个重要结构——长短时记忆网络(long short-term memory,LSTM)。本章也将介绍循环神经网络在自然语言处理(natural language processing,NLP)问题以及时序分析问题中的应用,并给出具体的TensorFlow程序来解决一些经典的问题。
1.首先将介绍循环神经网络的基本知识并通过机器翻译问题说明循环神经网络是如何被应用的。这一节将给出一个具体的样例来说明一个最简单的循环神经网络的前向传播时是如何工作的。
2.将介绍循环神经网络中最重要的结构——长短时记忆网络(long short term memory,LSTM)的网络结构。在这一节中将大致介绍LSTM结构中的主要元素,并给出具体的TensorFlow程序来实现一个使用了LSTM结构的循环神经网络。
3.接着将介绍一些常用的循环神经网络的变种。
4.最后将结合TensorFlow对这些网络结构的支持,通过两个经典的循环神经网络模型的应用案例,介绍如何针对语言模型和时序预测两个问题,设计和使用循环神经网络。

1.循环神经网络简介

循环神经网络(recurrent neural network, RNN)源自于Saratha Sathasivam提出的霍普菲尔德网络。霍普菲尔德网络因为实现困难,在其提出时并没有被合适地应用。该网络结构也于1986年后被全连接神经网络以及一些传统的机器学习算法所取代。然而,传统的机器学习算法非常依赖于人工提取的特征,使得基于传统机器学习的图像识别、语音识别以及自然语言处理等问题存在特征提取的瓶颈。而基于全连接神经网络的方法也存在参数太多、无法利用数据中时间序列信息等问题。随着更加有效的循环神经网络结构被不断提出,循环神经网络挖掘数据中的时序信息以及语义信息的深度表达能力被充分利用,并在语音识别、语言模型、机器翻译以及时序分析等方面实现了突破。
循环神经网络的主要用途是处理和预测序列数据。在之前介绍的全连接神经网络或卷积神经网络模型中,网络结构都是从输入层到隐藏层再到输出层,层与层之间是全连接或部分连接,但每层之间的节点是无连接的。考虑这样一个问题,如果要预测句子的下一个单词是什么,一般需要用到当前单词以及前面的单词,因为句子前后单词并不是独立的。比如,当前单词是“很”,前一个单词是“天空”,那么下一个单词很大概率是“蓝”。循环神经网络的来源就是为了刻画一个序列当前的输出与之前信息的关系。从网络结构上,循环神经网络会记忆之前的信息,并利用之前的信息影响后面节点的输出。也就是说,循环神经网络的隐藏层之间的节点是有连接的,隐藏层的输入不仅包括输入层的输出,还包括上一时刻隐藏层的输出。


循环神经网络经典结构示意图

上图展示了一个典型的循环神经网络。对于循环神经网络,一个非常重要的概念就是时刻。循环神经网络会对于每一个时刻的输入结合当前模型的状态给出一个输出。从上图可以看到,循环神经网络的主体结构A的输入除了来自输入层Xt,还有一个循环的边来提供当前时刻的状态。在每一个时刻,循环神经网络的模块A会读取t时刻的输入Xt,并输出一个值Ht。同时A的状态会从当前步传递到下一步。因此,循环神经网络理论上可以被看作是同一神经网络结构被无限复制的结果。但出于优化的考虑,目前循环神经网络无法做到真正的无限循环,所以,现实中一般会将循环体展开,于是可以得到下图所展示的结构。


循环神经网络按时间展开后的结构

在上图中可以更加清楚地看到循环神经网咯在每一个时刻会有一个输入Xt,然后根据循环神经网络当前的状态At提供一个输出Ht。而循环神经网络当前的状态At是根据上一时刻的状态At-1和当前的输入Xt共同决定的。从循环神经网络的结构特征可以很容易得出它最擅长解决的问题是与时间序列相关的。循环神经网络也是处理这类问题时最自然的神经网络结构。对于一个序列数据,可以将这个序列上不同时刻的数据依次传入循环神经网络的输入层,而输出可以是对序列中下一个时刻的预测,也可以是对当前时刻信息的处理结果(比如语音识别结果)。循环神经网络要求每一个时刻都有一个输入,但是不一定每个时刻都需要有输出。在过去几年中,循环神经网络已经被广泛地应用在语音识别、语言模型、机器翻译以及时序分析等问题上,并取得了巨大的成功。

循环神经网络实现序列预测示意图

以机器翻译为例来介绍循环神经网络是如何解决实际问题的。循环神经网络中每一个时刻的输入为需要翻译的句子中的单词。如上图所示,需要翻译的句子为ABCD,那么循环神经网络第一段每一个时刻的输入就分别是A、B、C 、D,然后用“”作为带翻译句子的结束符。在第一段中,循环神经网络没有输出。从结束符“”开始,循环神经网络进入翻译阶段。该阶段中每一个时刻的输入是上一个时刻的输出,而最终得到的输出就是句子ABCD翻译的结果。从上图可以看到句子ABCD对应的翻译结果就是XYZ,而Q是代表翻译结束的结束符。
如之前所介绍,循环神经网络可以被看作是同一神经网络结构在时间序列上被复制多次的结果,这个被复制多次的结构被称之为循环体。如何设计循环体的网络结构是循环神经网络解决实际问题的关键。和卷积神经网络过滤器中参数是共享的类似,在循环神经网络中,循环体网络结构中的参数在不同时刻也是共享的。
下图展示了一个使用最简单的循环体结构的循环神经网络:
使用单层全连接神经网络作为循环体的循环神经网络结构图

在这个循环体中只使用了一个类似全连接层的神经网络结构。下面将通过下图所展示的神经网络来介绍循环神经网络前向传播的完整流程。
循环神经网络中的状态是通过一个向量来表示的,这个向量的维度也称为循环神经网络隐藏层的大小,假设其为h,从图中可以看出,循环体中的神经网络的输入有两个部分,一部分为上一时刻的状态,另一部分为当前时刻的输入样本。对于时间序列数据来说(比如不同时刻商品的销量),每一时刻的输入样例可以是当前时刻的数值(比如销量值);对于语言模型来说,输入样例可以是当前单词对应的单词向量。
假设输入向量的维度为x,那么图中循环体的全连接层神经网络的输入大小为h+x。也就是将上一时刻的状态与当前时刻的输入拼接成一个大的向量作为循环体中神经网络的输入。因为该神经网络的输出为当前时刻的状态,于是输出层的节点个数也为h,循环体中的参数个数为(h+x)*h+h个。 从图中可以看到,循环体中的神经网络输出不但提供给了下一个时刻作为状态,同时也会提供给当前时刻的输出。为了将当前时刻的状态转化为最终的输出,循环神经网络还需要另外一个全连接神经网络来完成这个过程。这和卷积神经网络中最后的全连接层的意义是一样的。类似的,不同时刻用于输出的全连接神经网络中的参数也是一致的。为了让读者对于循环神经网络的前向传播有一个更加直观的认识,下图展示了一个循环神经网络前向传播的具体计算过程。
循环神经网络的前向传播的计算过程

在图中,假设状态的维度为2,输入、输出的维度都为1,而且循环体中的全连接层中权重为:

偏置项的大小为,用于输出的全连接层权重为:

偏置项大小为。那么在时刻t0,因为没有上一时刻,所以将状态初始化为[0,0],而当前的输入为1,所以拼接得到的向量为[0,0,1],通过循环体中的全连接层神经网络得到的结果为:

这个结果将作为下一时刻的输入状态,同时循环神经网络也会使用该状态生成输出。将该向量作为输入提供给用于输出的全连接神经网络可以得到t0时刻的最终输出:

使用t0时刻的状态可以类似地推导得出t1时刻的状态为[0.860,0.884],而t1时刻的输出为2.73.在得到循环神经网络的前向传播结果之后,可以和其他神经网络类似地定义损失函数。循环神经网络唯一的区别在于因为它每个时刻都有一个输出,所以循环神经网络的总损失为所有时刻(或者部分时刻)上的损失函数的总和。以下代码实现了这个简单的循环神经网络前向传播的过程:

  1. 定义RNN的参数。
import numpy as np
X = [1,2]
state = [0.0, 0.0]
# 分开定义不同输入部分的权重以方便操作
w_cell_state = np.asarray([[0.1, 0.2], [0.3, 0.4]])
w_cell_input = np.asarray([0.5, 0.6])
b_cell = np.asarray([0.1, -0.1])
# 定义用于输出的全连接层参数
w_output = np.asarray([[1.0], [2.0]])
b_output = 0.1
  1. 执行前向传播过程。
# 按照时间顺序执行循环神经网络的前向传播过程
for i in range(len(X)):
    # 计算循环体中的全连接层神经网络
    before_activation = np.dot(state, w_cell_state) + X[i] * w_cell_input + b_cell
    state = np.tanh(before_activation)
    # 根据当前时刻状态计算最终输出
    final_output = np.dot(state, w_output) + b_output
    # 输出每个时刻的信息
    print("before activation: ", before_activation)
    print("state: ", state)
    print("output: ", final_output)

运行代码,得到结果如下:


简单的循环神经网络前向传播的过程

和其他神经网络类似,在定义完损失函数之后,套用第4章中介绍的优化框架TensorFlow就可以自动完成模型训练的过程。这里唯一需要特别指出的是,理论上循环神经网络可以支持任意长度的序列,然而在实际中,如果序列过长会导致优化时出现梯度消散的问题,所以实际中一般会规定一个最大长度,当序列长度超过规定长度之后会对序列进行截断。

2.长短时记忆网络(LSTM)结构

循环神经网络工作的关键点就是使用历史的信息来帮助当前的决策。例如使用之前出现的单词来加强当前文字的理解。循环神经网络可以更好地利用传统神经网络结构所不能建模的信息,但同时,这也带来了更大的技术挑战——长期依赖(long-term dependences)问题。
在有些问题中,模型仅仅需要短期内的信息来执行当前的任务。比如预测短语“大海的颜色是蓝色”中的最后一个单词“蓝色”时,模型并不需要记忆这个短语之前更长的上下文信息——因为这一句话已经包含了足够的信息来预测最后一个词。在这样的场景中,相关的信息和待预测的词的位置之间的间隔很小,循环神经网络可以比较容易地利用先前信息。
但同样也会有一些上下文场景更加复杂的情况。比如当模型试着去预测段落“某地开设了大量工厂,空气污染十分严重。。。。。。这里的天空都是灰色的”的最后一个单词时,仅仅根据短期依赖就无法很好地解决这种问题。因为只根据最后一段,最后一个词可以使“蓝色的”或者“灰色的”。但如果模型需要预测清楚具体是什么颜色,就需要考虑先前提到但离当前位置较远的上下文信息。因此,当前预测位置和相关信息之间的文本间隔就有可能变得很大。当这个间隔不断增大时,类似下图给出的简单循环神经网络有可能会丧失学习到距离如此远的信息的能力:


循环神经网络结构图

或者在复杂语言场景中,有用信息的间隔有大有小、长短不一,循环神经网络的性能也会受到限制。
长短时记忆网络(long short term memory,LSTM)的设计就是为了解决这个问题,而循环神经网络被成功应用的关键就是LSTM。在很多的任务上,采用LSTM结构的循环神经网络比标准的循环神经网络表现更好。在下文中将重点介绍LSTM结构。LSTM结构是由Sepp Hochreiter和Jurgen Schmidhuber与1997年提出的,它是一种特殊的循环体结构。如下图所示,与单一的tanh循环体结构不同,LSTM是一种拥有三个“门”结构的特殊网络结构。


LSTM单元结构示意图

LSTM靠一些“门”的结构让信息有选择性地影响循环神经网络中每个时刻的状态。所谓“门”的结构就是一个使用sigmoid神经网络和一个按位做乘法的操作,这两个操作合在一起就是一个“门”的结构。之所有该结构叫做“门”是因为使用sigmoid作为激活函数的全连接神经网络层会输出一个0到1之间的数值,描述当前输入有多少信息量可以通过这个结构。于是这个结构的功能就类似于一扇门,当门打开时(sigmoid神经网络层输出为1),全部信息都可以通过;当门关上时(sigmoid神经网络层输出为0时),任何信息都无法通过。本节下面的篇幅将介绍每一个“门”是如何工作的。
为了使循环神经网络更有效的保存长期记忆,上图中的“遗忘门”和“输入门”至关重要,它们是LSTM结构的核心。“遗忘门”的作用是让循环神经网络“忘记”之前没有用的信息。比如一段文章中先介绍了某地原来是碧水蓝天,但后来被污染了。于是看到被污染了之后,循环神经网络应该“忘记”之前绿水蓝天的状态。这个工作是通过“遗忘门”来完成的。“遗忘门”会根据当前的输入Xt、上一时刻状态Ct-1和上一时刻输出Ht-1共同决定那一部分记忆需要被遗忘。在循环神经网络“忘记”了部分之前的状态后,它还需要从当前的输入补充最新的记忆。这个过程就是“输入门”完成的。如上图所示,“输入门”会根据Xt、Ct-1和Ht-1决定哪些部分将进入当前时刻的状态Ct。比如当看到文章中提到环境被污染之后,模型需要将这个信息写入新的状态。通过“遗忘门”和“输入门”,LSTM结构可以更加有效地决定哪些信息应该被遗忘,哪些信息应该得到保留。
LSTM结构在计算得到新的状态Ct后需要产生当前时刻的输出,这个过程是通过“输出门”来完成的。“输出门”会根据最新的状态Ct、上一时刻的输出Ht-1和当前的输入Xt来决定该时刻的输出Ht。比如当前的状态为被污染,那么“天空的颜色”后面的单词很可能就是“灰色的”。
相比前面图中展示的循环神经网络,使用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函数来生成全0的初始状态。
state = lstm.zero_state(batch_size,tf.float32)
# 定义损失函数
loss = 0.0
# 在第一节中介绍过,虽然理论上循环神经网络可以处理任意长度的序列,但是在训练时为了避免梯度消散的问题,会
# 规定一个最大的序列长度。在以下代码中,用num_steps来表示这个长度。
for i in range(num_steps):
    #在第一个时刻声明LSTM结构中使用的变量,在之后的时刻都需要复用之前定义好的变量。
    if i >0:
        tf.get_variable_scope().reuse_variables()
        # 每一步处理时间序列中的一个时刻。
        lstm_output, state = lstm(current_input,state)
        # 将当前时刻LSTM结构的输出传入一个全连接层得到最后的输出。
        final_output = fully_connected(lstm_output)
        # 计算当前时刻输出的损失
        loss += calc_loss(final_output,expected_output)
# 使用之前全连接神经网路介绍的方法训练模型

通过上面这段代码看到,通过TensorFlow可以非常方便地实现LSTM结构的循环神经网络,而且并不需要用户对LSTM内部结构有深入的了解。

3.循环神经网络的变种

在以上几节中已经完整地介绍了使用LSTM结构的循环神经网络。这一节将再介绍循环神经网络的几个常用变种以及它们所解决的问题,同时也会给出如何使用TensorFlow来实现这些变种。

3.1双向循环神经网络和深层循环神经网络

在经典的循环神经网络中,状态的传输是从前往后单向的。然而,在有些问题中,当前时刻的输出不仅和之前的状态有关系,也和之后的状态相关。这时就需要使用双向循环神经网络(bidirectional RNN)来解决这类问题。例如预测一个语句中缺失的单词不仅需要根据前文来判断,也需要根据后面的内容,这时双向循环网络就可以发挥它的作用。双向循环神经网络是由两个循环神经网络上下叠加在一起组成的。输出由这两个循环神经网络的状态共同决定的。下图展示了一个双向循环神经网络的结构图:


双向循环神经网络结构图

从上图中可以看到,双向循环神经网络的主体结构就是两个单项循环神经网络的结合。在每一个时刻t,输入会同时提供给这两个方向相反的循环神经网络,而输出则是由这两个单项循环神经网络共同决定。双向循环神经网络的前向传播过程和单项的循环神经网络十分类似,这里就不再赘述。更多关于双向神经网络的介绍可以参考Mike Schuster和Kuldip K.Paliwal发表的论文Bidirectional recurrent neural networks。
深层神经网络(deepRNN)是循环神经网络的另外一种变种。为了增强模型的表达能力,可以将每一个时刻上的循环体重复多次。下图给出了深层循环神经网络的结构示意图:


深层循环神经网络结构

从上图可以看到,相比第一节介绍的循环神经网络,深层循环神经网络在每一个时刻上将循环体结构复制了多次。和卷积神经网络类似,每一层的循环体中参数是一致的,而不同层中的参数可以不同。为了更好地支持深层循环神经网络,TensorFlow中提供了MultiRNNCell类来实现深层循环神经网络的前向传播过程。以下代码展示如何使用这个类:
# 定义一个基本的LSTM结构。深层神经网络也支持使用其他的循环体
lstm = rnn_cell.BasicLSTMCell(lstm_size)
# 通过MutiRNNCell类来实现深层循环神经网络中每一个时刻的前向传播过程。其中number_of_layers
# 表示了有多少层,也就是上图中从xt到ht需要经过多少个LSTM结构。
stacked_lstm = rnn_cell.MultiRNNCell([lstm]*number_of_layers)

# 和经典的循环神经网路一样,可以通过zero_state函数来获取初始状态
state = stacked_lstm.zero_state(batch_size,tf.float32)

# 计算每一时刻的前向传播结果
for i in range(num_steps):
    #在第一个时刻声明LSTM结构中使用的变量,在之后的时刻都需要复用之前定义好的变量。
    if i >0:
        tf.get_variable_scope().reuse_variables()
        # 每一步处理时间序列中的一个时刻。
        stacked_lstm_output, state = stacked_lstm(current_input,state)
        # 将当前时刻LSTM结构的输出传入一个全连接层得到最后的输出。
        final_output = fully_connected(stacked_lstm_output)
        # 计算当前时刻输出的损失
        loss += calc_loss(final_output,expected_output)

从以上代码可以看到,在TensorFlow中只需要在BasicLSTMCell的基础上再封装一层MultiRNNCell就可以非常容易地实现深层循环神经网络了。

3.2循环神经网络的dropout

在我们学习卷积神经网络时,介绍过在卷积神经网络上使用dropout的方法。通过dropout,可以让卷积神经网络更加健壮(robust)。类似的,在循环神经网络中使用dropout也有同样的功能。而且,类似卷积神经网络只在最后的全连接层中使用dropout,循环神经网络一般只在不同层循环体结构之间使用dropout,而不再同一层的循环体结构之间使用。也就是说从时刻t-1传递到时刻t时,循环神经网络不会进行状态的dropout;而在同一个时刻t中,不同层循环体之间会使用dropout。
如下图展示了循环神经网络使用dropout的示意图:


深层循环神经网络使用dropout示意图

图中实线箭头表示不使用dropout,虚线箭头表示使用dropout。
如上图,假设要从t-2时刻的输入Xt-2传递到t+1时刻的输出Yt+1,那么Xt-2将首先传入第一层循环体结构,这个过程会使用dropout。但是从t-2时刻的第一层循环体结构传递到第一层的t-1、t、t+1时刻不会使用dropout。在t+1时刻的第一层循环体结构传递到同一时刻内更高层的循环体结构时,会再次使用dropout。
在TensorFlow中,使用tf.nn.rnn_cell.DropoutWrapper类可以很容易实现dropout功能。以下代码展示了如何在TensorFlow中实现带dropout的循环体神经网络:

# 定义一个基本的LSTM结构。
lstm = rnn_cell.BasicLSTMCell(lstm_size)
# 使用DropoutWrapper类来实现dropout功能。一个参数为input_keep_prob,它可以用来控制输入的dropout概率;
# 另一个为output_keep_prob,它可以用来控制输出的dropout概率。
dropout_lstm = tf.nn.rnn_cell.DropoutWrapper(lstm,output_keep_prob=0.5)
# 在使用了dropout的基础上定义
stacked_lstm = rnn_cell.MultiRNNCell([dropout_lstm]*number_of_layers)

4.循环神经网络样例应用

在以上几节中已经介绍了不同循环神经网络的网络结构,并给出了具体的TensorFlow程序来实现这些循环神经网络的前向传播过程。这一节将给出两个具体的循环神经网络应用样例——自然语言建模和时序预测。这一节我们将介绍:
1.介绍什么是自然语言建模,并给出通过TensorFlow实现Penn TreeBank(PTB)数据集上的自然语言模型。
2.介绍TensorFlow的高层封装工具TFLearn,并通过TFLearn实现对函数sin x取值的预测。

4.1自然语言建模

简单地说,语言模型的目的是为了计算一个句子的出现概率。在这里把句子看成是单词的序列,于是语言模型需要计算的就是p(w1,w2,w3,...,wm)利用语言模型,可以确定哪个单词序列出现的可能性更大,或者给定若干个单词,可以预测下一个最可能出现的词语。举个音字转换的例子,假设输入的拼音串为“xianzaiquna”,它的输出可以是“西安在去哪”,也可以是“现在去哪”。根据语言常识可以知道,转换成第二个的概率更高。语言模型就可以得到后者的概率大于前者,因此在大多数情况下转换成后者比较合理。
那么如何计算一个句子的概率呢?首先一个句子可被看成是一个单词序列:
S = (w_1,w_2,w_3,w_4,w_5,...w_m)
其中m为句子的长度。那么,它的概率可以表示为:
p(S) = p(w_1,w_2,w_3,w_4,w_5,...w_m) =p(w_1)p(w_2|w_1)p(w_3|w_1,w_2)...p(w_m|w_1,w_2...w_{m-1})
要计算一个句子出现的概率,就需要知道上面公式中等式右边中每一项的取值。等式右边的每一项都是语言模型中的一个参数。一般来说,任何一门语言的词汇量都很大,词汇的组合更不计其数。为了顾及这些参数的取值,常见的方法有n-gram方法、决策树、最大熵模型、条件随机场、神经网络语言模型,等等。本小节将先以其中最简单的n-gram模型来介绍语言模型问题以及评价模型优劣的标准。n-gram模型有一个有限历史假设:当前单词的出现概率仅仅与前面的n-1个单词相关。因此以上公式可以近似为:
p(S) = p(w_1,w_2,w_3,...,w_m) = \prod_i^m p(w_{i-n+1,...,w_{i-1}})
n-gram模型里的n指的是当前单词依赖它前面的单词的个数。通常n可以取1、2/3,这时n-gram模型分别称为unigram、bigram和trigram语言模型。n-gram模型中需要估计的参数为条件概率
p(w_i|w_{i-n+1,...,w_{i-1}})
假设某种语言的单词表大小为k,那么n-gram模型需要顾及的不同参数数量为k^n.当n越大时,n-gram模型理论上越准确,但也越复杂,需要的计算量和训练预料数据量也就越大。因此,最常用的是bigram,其次是unigram和trigram。n取>=4的情况非常少。n-gram模型的参数一般采用最大似然估计(MLE)的方法计算:
p(w_i|w_{i-n+1,...w_{i-1}}) = \frac{C(w_{i-n+1,...,w_{i-1,w_i}})}{C(w_{i-n+1,...,w_{i-1}})}
其中C(X)表示单词序列X在训练语料中出现的次数。训练预料的规模越大,参数估计的结果越可靠。但即使训练数据的规模非常大时,还是会有很多单词序列在训练语料中没有出现过,这就会导致很多参数为0.举个例子来说,IBM使用了366M英语语料训练trigram,发现14.7%的trigram和2.2%的bigram在训练中没有出现。为了避免因为乘以0而导致整个概率为0,使用最大似然估计方法时都需要加入平滑避免参数取值为0.使用n-gram建立语言模型的细节不再赘述,感兴趣的读者可以参考书籍Information Retrieval:Implementing and Evaluating Search Engines.
语言模型效果好坏的常用评价指标是复杂度(perplexity)。简单来说,perplexity值刻画的就是通过某一个语言模型估计的一句话出现的概率。比如当已经知道
(w_1,w_2,w_3,...,w_m)
这句话出现在语料库中,那么通过语言模型计算得到的这句话的概率越高越好,也就是perplexity值越小越好。计算perplexity值的公式如下:


复杂度perplexity表示的概念其实是平均分支系数(average branch factor),即模型预测下一个词时的平均可选择数量。例如,考虑一个由0~9这10个数字随机组成的长度为m的序列。由于这10个数字出现的概率是随机的,所以每个数字出现的概率是十分之一。因此,在任意时刻,模型都有10个等概率的候选答案可以选择,于是perplexity就是10(有10个合理的答案)。perplexity的计算过程如下:

因此,如果一个语言模型的perplexity是89,就表示,平均情况下,模型预测下一个词时,有89个词等可能地作为下一个词的合理选择。另一种常用的perplexity表达形式如下:

相比乘积开根号的方式,使用加法的形式可以加速计算,这也有效地避免了概率为0时导致整个计算结果为0的问题。
除了n-gram模型,循环神经网络也可以用来对自然语言建模,如下图所示:
使用循环神经网络实现自然语言建模

如上图,每个时刻的输入为一个句子的单词,而每个时刻的输出为一个概率分布,表示句子下一个位置为不同单词的概率。通过这种方式,对于给定的句子,就可以通过循环神经网络的前向传播过程计算出
.比如在上图中,在第一个时刻输入的单词为“大海”,而输出为p(x|"大海").也就是在知道第一个词为“大海”后,其他不同单词出现在下一个位置的概率。比如从上图可以看出,p("的"|"大海")=0.8,也就是说“大海”之后的单词为“的”的概率为0.8.
类似的,通过循环神经网络可以求得概率p(x|"大海","的")、p(x|"大海","的","颜色")、p(x|"大海","的","颜色","是")。于是也可以求得整个句子“大海的颜色是蓝色”的概率,从而计算出这句话的perplexity值。在本小节后面的篇幅中将给出具体的TensorFlow代码来实现通过循环神经网络对自然语言建模。

PTB文本数据集介绍

PTB(Penn Treebank Dataset)文本数据集是语言模型学习中目前最广泛使用的数据集。本小节将在PTB数据集上使用循环神经网络实现语言模型。在给出语言模型代码之前将先简单介绍PTB数据集的格式以及TensorFlow对于PTB数据集的支持。首先需要下载来源于Tomas Mikolov网站上PTB数据。数据的下载地址为:
http://www.fit.vutbr.cz/~imikolov/rnnlm/simple-examples.tgz
将下载下来的文件解压之后可以得到如下文件夹列表:

这里只需关心data文件夹下的数据,对于其他文件不再一一介绍,感兴趣的读者可以自行参考README文件。在data文件夹下总共有7个文件,但这里将只会用到以下三个文件:



这三个数据文件中的数据已经经过了预处理,包含了10000个不同的词语和语句结束标记符(在文本中就是换行符)以及标记稀有词语的特殊符号<unk>。下面展示了训练数据中的一行:



为了让使用PTB数据集更加方便,TensorFlow提供了两个函数来帮助实现数据的预处理。首先,TensorFlow提供了ptb_raw_data函数来读取PTB的原始数据,并将原始数据中的单词转化为单词ID。以下代码展示了如何使用这个函数:
1.读取数据并打印长度及前100位数据。
import tensorflow as tf
import reader

DATA_PATH = "../../datasets/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函数

  1. 将训练数据组织成batch大小为4、截断长度为5的数据组。并使用队列读取前3个batch
# ptb_producer返回的为一个二维的tuple数据。
result = reader.ptb_producer(train_data, 4, 5)

# 通过队列依次读取batch。
with tf.Session() as sess:
    coord = tf.train.Coordinator()
    threads = tf.train.start_queue_runners(sess=sess, coord=coord)
    for i in range(3):
        x, y = sess.run(result)
        print "X%d: "%i, x
        print "Y%d: "%i, y
    coord.request_stop()
    coord.join(threads)

运行代码,结果如下图所示:


下图展示了ptb_iterator函数实现的功能:


将一个长序列分成batch并截断的操作

ptb_iterator函数会将一个长序列划分为batch_size段,其中batch_size为一个batch的大小。每次调用ptb_iterator时,该函数会从每一段中读取长度为num_step的子序列,其中num_step为截断的长度。从上面的输出可以看到,在第一个batch的第一行中,前面5个单词的ID和整个训练数据中前5个单词的ID是对应的。ptb_iterator在生成batch时可以会自动生成每个batch对应的正确答案,这个对于每一个单词,它对应的正确答案就是该单词的后面一个单词。

使用循环神经网络实现语言模型

在介绍了语言模型的理论和使用到的数据集之后,下面给出了一个完整的TensorFlow样例程序来通过循环神经网络实现语言模型:
1.定义变量

import numpy as np
import tensorflow as tf
from tensorflow.models.rnn.ptb import reader

2.定义相关的参数

# 数据存放的路径
DATA_PATH = "../../datasets/PTB_data"
# 隐藏层规模
HIDDEN_SIZE = 200
# 深层循环神经网络中LSTM结构的层数
NUM_LAYERS = 2
# 词典规模,加上语句结束标识符和稀有单词标识符总共一万个单词
VOCAB_SIZE = 10000

# 学习速率
LEARNING_RATE = 1.0
# 训练数据batch的大小
TRAIN_BATCH_SIZE = 20
# 训练数据截断长度
TRAIN_NUM_STEP = 35

# 测试数据batch的大小
EVAL_BATCH_SIZE = 1
# 测试数据截断长度
EVAL_NUM_STEP = 1
# 使用训练数据的轮数
NUM_EPOCH = 2
# 节点不被dropout的概率
KEEP_PROB = 0.5
# 用于控制梯度膨胀的参数
MAX_GRAD_NORM = 5

3.定义一个类来描述模型结构

class PTBModel(object):
    def __init__(self, is_training, batch_size, num_steps):
        # 记录使用的batch_size大小和截断长度
        self.batch_size = batch_size
        self.num_steps = num_steps
        
        # 定义输入层。可以看到输入层的维度为batch_size * num_steps,这和ptb_iterator函数输出的训练数据batch是一致的。
        self.input_data = tf.placeholder(tf.int32, [batch_size, num_steps])
        # 定义预期输出。它的维度和ptb_iterator函数输出的正确答案维度也是一样的。
        self.targets = tf.placeholder(tf.int32, [batch_size, num_steps])
        
        # 定义使用LSTM结构及训练时使用dropout。
        lstm_cell = tf.contrib.rnn.BasicLSTMCell(HIDDEN_SIZE)
        if is_training:
            lstm_cell = tf.contrib.rnn.DropoutWrapper(lstm_cell, output_keep_prob=KEEP_PROB)
        cell = tf.contrib.rnn.MultiRNNCell([lstm_cell]*NUM_LAYERS)
        
        # 初始化最初的状态。
        self.initial_state = cell.zero_state(batch_size, tf.float32)
        embedding = tf.get_variable("embedding", [VOCAB_SIZE, HIDDEN_SIZE])
        
        # 将原本单词ID转为单词向量。
        inputs = tf.nn.embedding_lookup(embedding, self.input_data)
        
        if is_training:
            inputs = tf.nn.dropout(inputs, KEEP_PROB)

        # 定义输出列表。
        outputs = []
        state = self.initial_state
        with tf.variable_scope("RNN"):
            for time_step in range(num_steps):
                if time_step > 0: tf.get_variable_scope().reuse_variables()
                cell_output, state = cell(inputs[:, time_step, :], state)
                outputs.append(cell_output) 
        output = tf.reshape(tf.concat(outputs, 1), [-1, HIDDEN_SIZE])
        weight = tf.get_variable("weight", [HIDDEN_SIZE, VOCAB_SIZE])
        bias = tf.get_variable("bias", [VOCAB_SIZE])
        logits = tf.matmul(output, weight) + bias
        
        # 定义交叉熵损失函数和平均损失。
        loss = tf.contrib.legacy_seq2seq.sequence_loss_by_example(
            [logits],
            [tf.reshape(self.targets, [-1])],
            [tf.ones([batch_size * num_steps], dtype=tf.float32)])
        self.cost = tf.reduce_sum(loss) / batch_size
        self.final_state = state
        
        # 只在训练模型时定义反向传播操作。
        if not is_training: return
        trainable_variables = tf.trainable_variables()

        # 控制梯度大小,定义优化方法和训练步骤。
        grads, _ = tf.clip_by_global_norm(tf.gradients(self.cost, trainable_variables), MAX_GRAD_NORM)
        optimizer = tf.train.GradientDescentOptimizer(LEARNING_RATE)
        self.train_op = optimizer.apply_gradients(zip(grads, trainable_variables))
  1. 使用给定的模型model在数据data上运行train_op并返回在全部数据上的perplexity值
def run_epoch(session, model, data, train_op, output_log, epoch_size):
    total_costs = 0.0
    iters = 0
    state = session.run(model.initial_state)

    # 训练一个epoch。
    for step in range(epoch_size):
        x, y = session.run(data)
        cost, state, _ = session.run([model.cost, model.final_state, train_op],
                                        {model.input_data: x, model.targets: y, model.initial_state: state})
        total_costs += cost
        iters += model.num_steps

        if output_log and step % 100 == 0:
            print("After %d steps, perplexity is %.3f" % (step, np.exp(total_costs / iters)))
    return np.exp(total_costs / iters)
  1. 定义主函数并执行
def main():
    train_data, valid_data, test_data, _ = reader.ptb_raw_data(DATA_PATH)

    # 计算一个epoch需要训练的次数
    train_data_len = len(train_data)
    train_batch_len = train_data_len // TRAIN_BATCH_SIZE
    train_epoch_size = (train_batch_len - 1) // TRAIN_NUM_STEP

    valid_data_len = len(valid_data)
    valid_batch_len = valid_data_len // EVAL_BATCH_SIZE
    valid_epoch_size = (valid_batch_len - 1) // EVAL_NUM_STEP

    test_data_len = len(test_data)
    test_batch_len = test_data_len // EVAL_BATCH_SIZE
    test_epoch_size = (test_batch_len - 1) // EVAL_NUM_STEP

    initializer = tf.random_uniform_initializer(-0.05, 0.05)
    with tf.variable_scope("language_model", reuse=None, initializer=initializer):
        train_model = PTBModel(True, TRAIN_BATCH_SIZE, TRAIN_NUM_STEP)

    with tf.variable_scope("language_model", reuse=True, initializer=initializer):
        eval_model = PTBModel(False, EVAL_BATCH_SIZE, EVAL_NUM_STEP)

    # 训练模型。
    with tf.Session() as session:
        tf.global_variables_initializer().run()

        train_queue = reader.ptb_producer(train_data, train_model.batch_size, train_model.num_steps)
        eval_queue = reader.ptb_producer(valid_data, eval_model.batch_size, eval_model.num_steps)
        test_queue = reader.ptb_producer(test_data, eval_model.batch_size, eval_model.num_steps)

        coord = tf.train.Coordinator()
        threads = tf.train.start_queue_runners(sess=session, coord=coord)

        for i in range(NUM_EPOCH):
            print("In iteration: %d" % (i + 1))
            run_epoch(session, train_model, train_queue, train_model.train_op, True, train_epoch_size)

            valid_perplexity = run_epoch(session, eval_model, eval_queue, tf.no_op(), False, valid_epoch_size)
            print("Epoch: %d Validation Perplexity: %.3f" % (i + 1, valid_perplexity))

        test_perplexity = run_epoch(session, eval_model, test_queue, tf.no_op(), False, test_epoch_size)
        print("Test Perplexity: %.3f" % test_perplexity)

        coord.request_stop()
        coord.join(threads)

if __name__ == "__main__":
    main()

运行结果,如下:


循环神经网络实现语言模型的程序运行结果

从输出可以看出,在迭代开始时perplexity值为10003.783,这基本相当于从一万个单词中随机选择下一个单词。而在训练结束后,在训练数据上的perplexity值降低到了179.420.这表明通过训练过程,将选择下一个单词的范围从一万个减小到了大约180个。通过调整LSTM隐藏层的节点个数和大小以及训练迭代的轮数可以将perplexity值降到更低。

4.2时间序列预测

本小节将介绍如何利用循环神经网络来预测正弦函数,下图给出了sin函数的函数图像:


sin函数曲线
使用TFLearn自定义模型

TensorFlow的另外一个高层封装TFLearn(集成在tf.contrib.learn里)对训练TensorFlow模型进行了一些封装,使其更便于使用。本小节将通过iris数据集简单介绍如何使用TFLearn实现分类问题。iris数据集需要通过4个特征(feature)来分辨三种类型的植物。iris数据集中总共包含了150个样本。以下程序展示了如何通过TFLearn快速的解决iris分类问题:
1.自定义Softmax回归模型

from sklearn import model_selection
from sklearn import datasets
from sklearn import metrics
import tensorflow as tf
import numpy as np
from tensorflow.contrib.learn.python.learn.estimators.estimator import SKCompat
learn = tf.contrib.learn

# 自定义模型,对于给定的输入数据(features)以及其对应的正确答案(target),返回在这些
# 输入上的预测值、损失值以及训练步骤。
def my_model(features, target):
    # 将预测的目标转换为one-hot编码的形式,因为共有三个类别,所以向量长度为3.经过转化后,
    # 第一个类别表示为(1,0,0),第二个为(0,1,0),第三个为(0,0,1)
    target = tf.one_hot(target, 3, 1, 0)
    
    # 计算预测值及损失函数。
    logits = tf.contrib.layers.fully_connected(features, 3, tf.nn.softmax)
    loss = tf.losses.softmax_cross_entropy(target, logits)
    
    # 创建优化步骤。
    train_op = tf.contrib.layers.optimize_loss(
        loss,
        tf.contrib.framework.get_global_step(),
        optimizer='Adam',
        learning_rate=0.01)
    # 返回在给定数据上的预测结果、损失值以及优化步骤。
    return tf.arg_max(logits, 1), loss, train_op
  1. 读取数据并将数据转化成TensorFlow要求的float32格式
# 加载iris数据集,并划分为训练集合和测试集合。
iris = datasets.load_iris()
x_train, x_test, y_train, y_test = model_selection.train_test_split(
    iris.data, iris.target, test_size=0.2, random_state=0)

x_train, x_test = map(np.float32, [x_train, x_test])
  1. 封装和训练模型,输出准确率。
# 对自定义的模型进行封装
classifier = SKCompat(learn.Estimator(model_fn=my_model, model_dir="Models/model_1"))
# 使用封装好的模型和训练数据执行100轮迭代
classifier.fit(x_train, y_train, steps=800)
# 使用训练好的模型进行结果预测
y_predicted = [i for i in classifier.predict(x_test)]
# 计算模型的准确度
score = metrics.accuracy_score(y_test, y_predicted)
print('Accuracy: %.2f%%' % (score * 100))

运行代码,结果如下图所示:


使用TFLearn自定义模型代码运行结果

通过以上程序可以看出TFLearn既封装了一些常用的神经网络结构,又省去了模型训练的部分,这让TensorFlow的程序可以变得更加简短。

预测正弦函数

通过TFLearn,下面的篇幅将给出具体的TensorFlow程序来实现预测正弦函数sin。因为标准的循环神经网络模型预测的是离散的数值,所以在程序中需要将连续的sin函数曲线离散化。所谓离散化就是在一个给定的区间[0,max]内,通过有限个采样点模拟一个连续的曲线。比如在以下程序中每隔sample_iterval对sin函数进行一次采样,采样得到的序列就是sin函数离散化之后的结果。以下程序为预测离散化之后的sin函数:

  1. 设置神经网络的参数
import numpy as np
import tensorflow as tf
from tensorflow.contrib.learn.python.learn.estimators.estimator import SKCompat
from tensorflow.python.ops import array_ops as array_ops_
import matplotlib.pyplot as plt
learn = tf.contrib.learn

HIDDEN_SIZE = 30
NUM_LAYERS = 2

TIMESTEPS = 10
TRAINING_STEPS = 3000
BATCH_SIZE = 32

TRAINING_EXAMPLES = 10000
TESTING_EXAMPLES = 1000
SAMPLE_GAP = 0.01
  1. 定义生成正弦数据的函数
def generate_data(seq):
    X = []
    y = []

    for i in range(len(seq) - TIMESTEPS - 1):
        X.append([seq[i: i + TIMESTEPS]])
        y.append([seq[i + TIMESTEPS]])
    return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32)
  1. 定义lstm模型。
def lstm_model(X, y):
    lstm_cell = tf.contrib.rnn.BasicLSTMCell(HIDDEN_SIZE, state_is_tuple=True)
    cell = tf.contrib.rnn.MultiRNNCell([lstm_cell] * NUM_LAYERS)
    
    output, _ = tf.nn.dynamic_rnn(cell, X, dtype=tf.float32)
    output = tf.reshape(output, [-1, HIDDEN_SIZE])
    
    # 通过无激活函数的全联接层计算线性回归,并将数据压缩成一维数组的结构。
    predictions = tf.contrib.layers.fully_connected(output, 1, None)
    
    # 将predictions和labels调整统一的shape
    labels = tf.reshape(y, [-1])
    predictions=tf.reshape(predictions, [-1])
    
    loss = tf.losses.mean_squared_error(predictions, labels)
    
    train_op = tf.contrib.layers.optimize_loss(
        loss, tf.contrib.framework.get_global_step(),
        optimizer="Adagrad", learning_rate=0.1)

    return predictions, loss, train_op

4.进行训练

# 封装之前定义的lstm。
regressor = SKCompat(learn.Estimator(model_fn=lstm_model,model_dir="Models/model_2"))

# 生成数据。
test_start = TRAINING_EXAMPLES * SAMPLE_GAP
test_end = (TRAINING_EXAMPLES + TESTING_EXAMPLES) * SAMPLE_GAP
train_X, train_y = generate_data(np.sin(np.linspace(
    0, test_start, TRAINING_EXAMPLES, dtype=np.float32)))
test_X, test_y = generate_data(np.sin(np.linspace(
    test_start, test_end, TESTING_EXAMPLES, dtype=np.float32)))

# 拟合数据。
regressor.fit(train_X, train_y, batch_size=BATCH_SIZE, steps=TRAINING_STEPS)

# 计算预测值。
predicted = [[pred] for pred in regressor.predict(test_X)]

# 计算MSE。
rmse = np.sqrt(((predicted - test_y) ** 2).mean(axis=0))
print ("Mean Square Error is: %f" % rmse[0])

运行结果如下图:


训练模型运行结果
  1. 画出预测值和真实值的曲线
plot_predicted, = plt.plot(predicted, label='predicted')
plot_test, = plt.plot(test_y, label='real_sin')
plt.legend([plot_predicted, plot_test],['predicted', 'real_sin'])
plt.show()

运行代码,如下图:


预测值和真实值的曲线图
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容