在图像领域,最流行的 building block 大多以卷积网络为主。上一篇我们介绍了转载|使用PaddleFluid和TensorFlow实现图像分类网络SE_ResNeXt。卷积网络本质上依然是一个前馈网络,在神经网络基本单元中循环神经网络是建模序列问题最有力的工具, 有着非常重要的价值。自然语言天生是一个序列,在自然语言处理领域(Nature Language Processing,NLP)中,许多经典模型都基于循环神经网络单元。可以说自然语言处理领域是 RNN 的天下。
这一篇以 NLP 领域的 RNN 语言模型(RNN Language Model,RNN LM)为实验任务,对比如何使用 PaddleFluid 和 TensorFlow 两个平台实现序列模型。 这一篇中我们会看到 PaddleFluid 和 TensorFlow 在处理序列输入时有着较大的差异:PaddleFluid 默认支持非填充的 RNN 单元,在如何组织 mini-batch 数据提供序列输入上也简化很多。
如何使用代码本篇文章配套有完整可运行的代码, 请从随时从 github [1] 上获取最新代码。代码包括以下几个文件:
注意:在运行模型训练之前,请首先进入 data 文件夹,在终端运行 sh download.sh 下载训练数据。
在终端运行以下命令便可以使用默认结构和默认参数运行 PaddleFluid 训练 RNN LM。
python rnnlm_fluid.py
在终端运行以下命令便可以使用默认结构和默认参数运行 TensorFlow 训练 RNN LM。
python rnnlm_tensorflow.py
背景介绍
one-hot和词向量表示法
计算机如何表示语言是处理 NLP 任务的首要问题。这里介绍将会使用到的 one-hot 和词向量表示法。
one-hot 表示方法:一个编码单元表示一个个体,也就是一个词。于是,一个词被表示成一个长度为字典大小的实数向量,每个维度对应字典里的一个词,除了该词对应维度上的值是 1,其余维度都是 0。
词向量表示法:与 one-hot 表示相对的是 distributed representation ,也就是常说的词向量:用一个更低维度的实向量表示词语,向量的每个维度在实数域 RR 取值。
在自然语言处理任务中,一套好的词向量能够提供丰富的领域知识,可以通过预训练获取,或者与最终任务端到端学习而来。
循环神经网络
循环神经网络(Recurrent Neural Network)是一种对序列数据建模的重要单元,模拟了离散时间(这里我们只考虑离散时间)动态系统的状态演化。“循环” 两字刻画了模型的核心:上一时刻的输出作为下一个时刻的输入,始终留在系统中如下面的图 1 所示,这种循环反馈能够形成复杂的历史。自然语言是一个天生的序列输入,RNN 恰好有能力去刻画词汇与词汇之间的前后关联关系,因此,在自然语言处理任务中占有重要的地位。
▲ 图1. 最简单的RNN单元
RNN 形成“循环反馈” 的过程是一个函数不断复合的过程,可以等价为一个层数等于输入序列长度的前馈神经网络,如果输入序列有 100 个时间步,相当于一个 100 层的前馈网络,梯度消失和梯度爆炸的问题对 RNN 尤为严峻。
直觉上大于 1 的数连乘越乘越大,极端时会引起梯度爆炸;小于 1 的数连乘越乘越小,极端时会引起梯度消失。梯度消失也会令在循环神经网络中,后面时间步的信息总是会”压过”前面时间步。如果 t 时刻隐层状态依赖于 t 之前所有时刻,梯度需要通过所有的中间隐层逐时间步回传,这会形成如图 2 所示的一个很深的求导链。
▲ 图2. t时刻依赖t时刻之前所有时刻
在许多实际问题中时间步之间相互依赖的链条并没有那么长,t 时刻也许仅仅依赖于它之前有限的若干时刻。很自然会联想到:如果模型能够自适应地学习出一些如图 3 所示的信息传播捷径来缩短梯度的传播路径,是不是可以一定程度减梯度消失和梯度爆炸呢?答案是肯定的,这也就是 LSTM 和 GRU 这类带有 “门控”思想的神经网络单元。
▲ 图3. 自适应地形成一些信息传播的“捷径”
关于 LSTM 更详细的介绍请参考文献 [2],这里不再赘述,只需了解 LSTM/GUR 这些门控循环神经网络单元提出的动机即可。
RNN LM
语言模型是 NLP 领域的基础任务之一。语言模型是计算一个序列的概率,判断一个序列是否属于一个语言的模型,描述了这样一个条件概率,其中是输入序列中的 T 个词语,用 one-hot 表示法表示。
言模型顾名思义是建模一种语言的模型,这一过程如图 4 所示:
▲ 图4. RNN语言模型
RNN LM的工作流程如下:
1. 给定一段 one-hot 表示的输入序列 {x1,x2,...,xT},将它们嵌入到实向量空间,得到词向量表示 :{ω1,ω2,...,ωt}。
2. 以词向量序列为输入,使用 RNN 模型(可以选择LSTM或者GRU),计算输入序列到 t 时刻的编码 ht。
3. softmax 层以 ht 为输入,预测下一个最可能的词的概率。
4. ,根据和计算误差信号。
PTB数据集介绍至此,介绍完 RNN LM 模型的原理和基本结构,下面准备开始分别使用 PaddleFluid 和 TensorFlow 来构建我们的 训练任务。这里首先介绍这一篇我们使用 Mikolov 与处理过的 PTB 数据,这是语言模型任务中使用最为广泛的公开数据之一。 PTB 数据集包含 10000 个不同的词语(包含句子结束符
通过运行 data 目录下的 download.sh 下载数据,我们将使用其中的 ptb.train.txt 文件进行训练,文件中一行是一句话,文本中的低频词已经全部被替换为
这一节我们首先整体总结一下使用 PaddleFluid 平台和 TensorFlow 运行自己的神经网络模型都有哪些事情需要完成。
PaddleFluid
1. 调用 PaddleFluid API 描述神经网络模型。PaddleFluid 中一个神经网络训练任务被称之为一段 Fluid Program 。
2. 定义 Fluid Program 执行设备: place 。常见的有 fluid.CUDAPlace(0) 和fluid.CPUPlace()
place = fluid.CUDAPlace(0) if conf.use_gpu else fluid.CPUPlace()
注:PaddleFluid 支持混合设备运行,一些运算(operator)没有特定设备实现,或者为了提高全局资源利用率,可以为他们指定不同的计算设备。
3. 创建 PaddleFluid 执行器(Executor),需要为执行器指定运行设备。
exe = fluid.Executor(place)
让执行器执行 fluid.default_startup_program() ,初始化神经网络中的可学习参数,完成必要的初始化工作。
5. 定义 DataFeeder,编写 data reader,只需要关注如何返回一条训练/测试数据。
6. 进入训练的双层循环(外层在 epoch 上循环,内层在 mini-batch 上循环),直到训练结束。
TensorFlow
1. 调用 TensorFlow API 描述神经网络模型。 TensorFlow 中一个神经网络模型是一个 Computation Graph。
2. 创建TensorFlow Session用来执行计算图。
sess = tf.Session()
3. 调用 sess.run(tf.global_variables_initializer()) 初始化神经网络中的可学习参数。
4. 编写返回每个 mini-batch 数据的数据读取脚本。
5. 进入训练的双层循环(外层在 epoch 上循环,内层在 mini-batch 上循环),直到训练结束。
如果不显示地指定使用何种设备进行训练,TensorFlow 会对机器硬件进行检测(是否有 GPU), 选择能够尽可能利用机器硬件资源的方式运行。
从以上的总结中可以看到,PaddleFluid 程序和 TensorFlow 程序的整体结构非常相似,使用经验可以非常容易的迁移。
构建网络结构及运行训练加载训练数据
PaddleFluid
定义 输入data layers
PaddleFluid 模型通过 fluid.layers.data 来接收输入数据。图像分类网络以图片以及图片对应的类别标签作为网络的输入:
word = fluid.layers.data(name="current_word", shape=[1], dtype="int64", lod_level=1)
lbl = fluid.layers.data(name="next_word", shape=[1], dtype="int64", lod_level=1)
1. 定义 data layer 的核心是指定输入 Tensor 的形状( shape )和类型。
2. RNN LM 使用 one-hot 作为输入,一个词用一个和字典大小相同的向量表示,每一个位置对应了字典中的 一个词语。one-hot 向量仅有一个维度为 1, 其余全部为 0。因此为了节约存储空间,通常都直接用一个整型数表示给出词语在字典中的 id,而不是真的创建一个和词典同样大小的向量 ,因此在上面定义的 data layer 中 word 和 lbl 的形状都是 1,类型是 int64 。
3. 需要特别说明的是,实际上 word 和 lbl 是两个 [batch_size x 1] 的向量,这里的 batch size 是指一个 mini-batch 中序列中的总词数。对序列学习任务, mini-batch 中每个序列长度 总是在发生变化,因此实际的 batch_size 只有在运行时才可以确定。 batch size 总是一个输入 Tensor 的第 0 维,在 PaddleFluid 中指定 data layer 的 shape 时,不需要指定 batch size的大小,也不需要考虑占位。框架会自动补充占位符,并且在运行时 设置正确的维度信息。因此,上面的两个 data layer 的 shape 都只需要设置第二个维度,也就是 1。
LoD Tensor和Non-Padding的序列输入
与前两篇文章中的任务相比,在上面的代码片段中定义 data layer 时,出现了一个新的lod_level 字段,并设置为 1。这里就要介绍在 Fluid 系统中表示序列输入的一个重要概念 LoDTensor。
那么,什么是 LoD(Level-of-Detail) Tensor 呢?
1. Tensor 是 nn-dimensional arry 的推广,LoDTensor 是在 Tensor 基础上附加了序列信息。
2. Fluid 中输入、输出,网络中的可学习参数全部统一使用 LoDTensor(n-dimension array)表示,对非序列数据,LoD 信息为空。一个 mini-batch 输入数据是一个 LoDTensor。
3. 在 Fluid 中,RNN 处理变长序列无需 padding,得益于 LoDTensor表示。
4. 可以简单将 LoD 理解为:std::vector
下图是 LoDTensor 示意图(图片来自 Paddle 官方文档):
▲ 图5. LoD Tensor示意图
LoD 信息是附着在一个 Tensor 的第 0 维(也就是 batch size 对应的维度),来对一个 batch 中的数据进一步进行划分,表示了一个序列在整个 batch 中的起始位置。
LoD 信息可以嵌套,形成嵌套序列。例如,NLP 领域中的段落是一种天然的嵌套序列,段落是句子的序列,句子是词语的序列。
LoD 中的 level 就表示了序列信息的嵌套:
图 (a) 的 LoD 信息 [0, 5, 8, 10, 14] :这个 batch 中共含有 4 条序列。
图 (b) 的 LoD 信息 [[0, 5, 8, 10, 14] /*level=1*/, [0, 2, 3, 5, 7, 8, 10, 13, 14] /*level=2*/] :这个 batch 中含有嵌套的双层序列。
有了 LoDTensor 这样的数据表示方式,用户不需要对输入序列进行填充,框架会自动完成 RNN 的并行计算处理。
如何构造序列输入信息
明白了 LoD Tensor 的概念之后,另一个重要的问题是应该如何构造序列输入。在 PaddleFluid 中,通过 DataFeeder 模块来为网络中的 data layer 提供数据,调用方式如下面的代码所示:
train_reader = paddle.batch(paddle.reader.shuffle(train_data, buf_size=51200),batch_size=conf.batch_size)place = fluid.CUDAPlace(0) if conf.use_gpu else fluid.CPUPlace()
feeder = fluid.DataFeeder(feed_list=[word, lbl], place=place)
观察以上代码,需要用户完成的仅有:编写一个实现读取一条数据的 python 函数: train_data。 train_data 的代码非常简单,我们再来看一下它的具体实现 [3]:
def train_data(data_dir="data"):data_path = os.path.join(data_dir, "ptb.train.txt")_, word_to_id = build_vocab(data_path)with open(data_path, "r") as ftrain:for line in ftrain:words = line.strip().split()word_ids = [word_to_id[w] for w in words]yield word_ids[0:-1], word_ids[1:]
在上面的代码中:
1. train_data 是一个 python generator ,函数名字可以任意指定,无需固定。
2. train_data 打开原始数据数据文件,读取一行(一行既是一条数据),返回一个 python list,这个 python list 既是序列中所有时间步。具体的数据组织方式如下表所示(其中,f 代表一个浮点数,i 代表一个整数):
3. paddle.batch() 接口用来构造 mini-batch 输入,会调用 train_data 将数据读入一个 pool 中,对 pool 中的数据进行 shuffle,然后依次返回每个 mini-batch 的数据。
TensorFlow
TensorFlow 中使用占位符 placeholder 接收 训练数据,可以认为其概念等价于 PaddleFluid 中的 data layer。同样的,我们定义了如下两个 placeholder 用于接收当前词与下一个词语:
def placeholders(self):self._inputs = tf.placeholder(tf.int32,[None, self.max_sequence_length])self._targets = tf.placeholder(tf.int32, [None, self.vocab_size])
1. placeholder 只存储一个 mini-batch 的输入数据。与 PaddleFluid 中相同, _inputs 这里接收的是 one-hot 输入,也就是该词语在词典中的 index,one-hot 表示 会进一步通过此词向量层的作用转化为实值的词向量表示。
2. 需要注意的是,TensorFlow 模型中网络输入数据需要进行填充,保证一个 mini-batch 中序列长度 相等。也就是一个 mini-batch 中的数据长度都是 max_seq_length ,这一点与 PaddleFluid 非常不同。
通常做法 是对不等长序列进行填充,在这一篇示例中我们使用一种简化的做法,每条训练样本都按照 max_sequence_length 来切割,保证一个 mini-batch 中的序列是等长的。
于是, _input 的 shape=[batch_size, max_sequence_length] 。 max_sequence_length即为 RNN 可以展开长度。
构建网络结构
PaddleFluid RNN LM
这里主要关注最核心的 LSTM 单元如何定义:
def __rnn(self, input):for i in range(self.num_layers):hidden = fluid.layers.fc(size=self.hidden_dim * 4,bias_attr=fluid.ParamAttr(initializer=NormalInitializer(loc=0.0, scale=1.0)),input=hidden if i else input)lstm = fluid.layers.dynamic_lstm(input=hidden,size=self.hidden_dim * 4,candidate_activation="tanh",gate_activation="sigmoid",cell_activation="sigmoid",bias_attr=fluid.ParamAttr(initializer=NormalInitializer(loc=0.0, scale=1.0)),is_reverse=False)return lstm
PaddleFluid 中的所有 RNN 单元(RNN/LSTM/GRU)都支持非填充序列作为输入,框架会自动完成不等长序列的并行处理。当需要堆叠多个 LSTM 作为输入时,只需利用 Python 的 for 循环语句,让一个 LSTM 的输出成为下一个 LSTM 的输入即可。在上面的代码片段中有一点需要特别注意:PaddleFluid 中的 LSTM 单元是由 fluid.layers.fc+ fluid.layers.dynamic_lstm共同构成的。
▲ 图6. LSTM计算公式
图 6 是 LSTM 计算公式,图中用红色圈起来的计算是每一时刻输入矩阵流入三个门和 memory cell 的之前的映射。PaddleFluid 将这个四个矩阵运算合并为一个大矩阵一次性计算完毕,fluid.layers.dynamic_lstm 不包含这部分运算。因此:
1. PaddleFluid 中的 LSTM 单元是由 fluid.layers.fc + fluid.layers.dynamic_lstm 。
2. 假设 LSTM 单元的隐层大小是 128 维, fluid.layers.fc 和 fluid.layers.dynamic_lstm 的 size 都应该设置为 128 * 4,而不是 128。
TensorFlow RNN LM
这里主要关注最核心的 LSTM 单元如何定义:
def rnn(self):def lstm_cell():return tf.contrib.rnn.BasicLSTMCell(self.hidden_dim, state_is_tuple=True)cells = [lstm_cell() for _ in range(self.num_layers)]cell = tf.contrib.rnn.MultiRNNCell(cells, state_is_tuple=True)_inputs = self.input_embedding()_outputs, _ = tf.nn.dynamic_rnn(cell=cell, inputs=_inputs, dtype=tf.float32)last = _outputs[:, -1, :]logits = tf.layers.dense(inputs=last, units=self.vocab_size)prediction = tf.nn.softmax(logits)
tf.nn.rnn_cell.BasicLSTMCell(n_hidden, state_is_tuple=True) : 是最基本的 LSTM 单元。n_hidden 表示 LSTM 单元隐层大小。 state_is_tuple=True 表示返回的状态用一个元祖表示。
tf.contrib.rnn.MultiRNNCell : 用来 wrap 一组序列调用的 RNN 单元的 wrapper。
tf.nn.dynamic_rnn : 通过指定 mini-batch 中序列的长度,可以跳过 padding 部分的计算,减少计算量。这一篇的例子中由于我们对输入数据进行了处理,将它们都按照max_sequence_length 切割。
但是, dynamic_rnn 可以让不同 mini-batch 的 batch size 长度不同,但同一次迭代一个 batch 内部的所有数据长度仍然是固定的。
运行训练
运行训练任务对两个平台都是常规流程,可以参考上文在程序结构一节介绍的流程,以及代码部分:PaddleFluid vs. TensorFlow,这里不再赘述。
总结这一篇我们第一次接触 PaddleFluid 和 TensorFlow 平台的序列模型。了解 PaddleFluid 和 TensorFlow 在接受序列输入,序列处理策略上的不同。序列模型是神经网络模型中较为复杂的一类模型结构,可以衍生出非常复杂的模型结构。
不论是 PaddleFluid 以及 TensorFlow 都实现了多种不同的序列建模单元,如何选择使用这些不同的序列建模单元有很大的学问。到目前为止平台使用的一些其它重要主题:例如多线程多卡,如何利用混合设备计算等我们还尚未涉及。接下来的篇章将会继续深入 PaddleFluid 和 TensorFlow 平台的序列模型处理机制,以及更多重要功能如何在两个平台之间实现。
参考文献[1]. 本文配套代码
https://github.com/JohnRabbbit/TF2Fluid/tree/master/03_rnnlm
[2]. Understanding LSTM Networks
http://colah.github.io/posts/2015-08-Understanding-LSTMs/
[3]. train_data具体实现
https://github.com/JohnRabbbit/TF2Fluid/blob/master/03_rnnlm/load_data_fluid.py