文章

Tensorflow - LSTM and PTB-Word-LM

从例子说起RNN

PTB是什么

PTB是Penn Tree Bank的缩写,是NLP领域里常见一个数据集,用来评测各种模型的质量。原文网站请狂击 Tensorflow的PTB-Word-LM的例子是在这个数据集的基础之上,训练一个语言模型,代码在这里。本文接下来将针对这个例子做详细的分析。 源代码中包含两个文件reader.pyptb_word_lm.py,前者是数据处理代码,读取ptb语料数据生成对应的Tensor;后者是RNN模型训练的代码。 Tensorflow的Tutorials页面在这里不过其实写的挺简略的,如果是想把模型跑一遍倒是足够的了。

RNN & LSTM

讲解RNN或者LSTM是什么又可以扩展出去一万字也不一定写得完,所以在继续阅读之前强烈建议看一下这篇写的极好的科普文Understanding LSTM Networks以及The unreasonable Effectiveness of Recurrent Neural Networks。这两篇文章写的都很通俗易懂,难得的好文章。

在开始分析例子之前再稍微说明一下这个例子是干嘛的。PTB-Word-LM这个例子训练了一个语言模型,这个模型在给定的Context(Word Sequence)下输出下一个Word的分布。语言模型在NLP里是非常基础的一类模型,在很多领域都有用武之地,但是本身语言模型也是很容易理解以及容易训练的,这应该也是tf选择语言模型作为例子的原因之一。

整个例子可以由以下的流程来描述:

原始数据处理

  • 读取原始文件,建立word –> id的映射字典。熟悉sklearn的同学可以理解为LabelTransformer
  • 分割语料数据。把所有数据分为训练集、验证集和测试集。每个数据集都按照RNN的方式重新组织了一下语料。

建立模型

  • 首先做Word embedding,如果有训练好的embedding模型也可以直接加载进来,例子是训练一个新的。
  • 创建多层LSTM网络,层次是可以配置的,中间加了一些dropout过程。
  • 创建Softmax输出,解析多层LSTM的结果给每个word打一个分。
  • 创建Loss Function和优化方法,梯度下降应用参数时增加了gradient clip防止gradient explosion。

运行和测试模型

  • 反复迭代优化模型
  • 输出训练、验证和测试集上的效果

以下将就这3个大的部分分别介绍。

原始数据处理

PTB的数据本身就分为了train/valid/test,所以不需要再分割一次只需要加载即可。以下是一段语料:

aer banknote berlitz calloway centrust cluett fromstein gitano guterman hydro-quebec ipo kia memotec mlx nahb punts rake regatta rubens sim snack-food ssangyong swapo wachter pierre N years old will join the board as a nonexecutive director nov. N mr. is chairman of n.v. the dutch publishing group rudolph N years old and former chairman of consolidated gold fields plc was named a nonexecutive director of this british industrial conglomerate a form of asbestos once used to make kent cigarette filters has caused a high percentage of cancer deaths among a group of workers exposed to it more than N years ago researchers reported

仔细看PTB里还是有一些有趣的地方的,譬如数字的位置都被N代替,所有字母均为小写等。

原始数据读进来之后所有\n被替换为了<eos>合并为一行,然后按照空格分隔,给每个分隔出来的word一个id。

遗留问题:有一个问题还未解决,这个例子里把所有句子合并在了一起。在训练中<eos>前后的文本可能在一个时间序列中被训练,这样做完全没有问题吗?还是说因为这里仅仅是个例子的原因就这么处理掉了。

原始数据处理这块最重要的是函数ptb_producer,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def ptb_producer(raw_data, batch_size, num_steps, name=None):
  """Iterate on the raw PTB data.
  This chunks up raw_data into batches of examples and returns Tensors that
  are drawn from these batches.
  Args:
    raw_data: one of the raw data outputs from ptb_raw_data.
    batch_size: int, the batch size.
    num_steps: int, the number of unrolls.
    name: the name of this operation (optional).
  Returns:
    A pair of Tensors, each shaped [batch_size, num_steps]. The second element
    of the tuple is the same data time-shifted to the right by one.
  Raises:
    tf.errors.InvalidArgumentError: if batch_size or num_steps are too high.
  """
  with tf.name_scope(name, "PTBProducer", [raw_data, batch_size, num_steps]):
    raw_data = tf.convert_to_tensor(raw_data, name="raw_data", dtype=tf.int32)

    data_len = tf.size(raw_data)
    batch_len = data_len // batch_size
    data = tf.reshape(raw_data[0 : batch_size * batch_len],
                      [batch_size, batch_len])

    epoch_size = (batch_len - 1) // num_steps
    assertion = tf.assert_positive(
        epoch_size,
        message="epoch_size == 0, decrease batch_size or num_steps")
    with tf.control_dependencies([assertion]):
      epoch_size = tf.identity(epoch_size, name="epoch_size")

    i = tf.train.range_input_producer(epoch_size, shuffle=False).dequeue()
    x = tf.slice(data, [0, i * num_steps], [batch_size, num_steps])
    y = tf.slice(data, [0, i * num_steps + 1], [batch_size, num_steps])
    return x, y

解释一下这个函数,输入参数:

  • raw_data,就是原始数据了,一个word id组成的list
  • batch_size,数据分成多少个batch,训练的时候是一个batch一个batch的一起输入进模型的
  • num_steps,或者叫time_steps,unrolled cells的个数或者说是RNN展开之后的长度,再换个说法我觉得可以理解为context window的大小。

输出结果就很好理解了,两个Tensor x 和 y。

#17 ~ 22 首先把word id list转换成了一个一维的tensor,然后根据整体数据的长度计算出每一个batch的长度,然后把数据变换为一个二维的shape = (batch_size, batch_len)的tensor,这里直接丢弃了最后不满足一个batch大小的数据。

#24 ~ 29 首先计算一下对于每一个batch,需要多少次迭代。因为是通过预测下一个字符的来构建语言模型,属于RNN里Many to Many的问题,而且输入和输出的长度是一样的,因此每次迭代输入的x, y都是一个长度为num_steps的序列,而y序列是x序列向后平移一个word的序列,因此迭代次数就是(batch_len - 1) // num_steps,最后不足num_steps数量的word就被扔掉了。

#31 ~ 34 这段代码就是把之前生成的shape = (batch_size, batch_len)的数据切分成epoch_size个slice。这段代码有一点点绕,我稍微解释一下。tf.slice返回的是一个slice,所以每次迭代的时候x和y都返回的是一个shape = (batch_size, num_steps)的tensor。然而计算x和y的时候,传入的begin参数是通过i来计算得到的,i在每次迭代的时候会从一个队列里弹出最新的值作为他的值,这个队列就是range_input_producer这个函数生成的,一个FIFO队列,值从0epoch_size - 1,一共epoch_size个元素,而每个batch都会迭代epoch_size次,所以就把数据取完了。另外多说一句,Queue这里会有坑,单独去调试这一段代码是会hang住的,原因是tf需要queue runner去运行队列。调试方法可以参考reader_test.py

遗留问题:这里在还有个问题,在生成数据的时候可以看到长度为num_steps的窗口之间是没有重叠部分的,但是这样做不会使每个窗口开始的word损失上文数据吗?虽然final state在一个batch中是传递的,但是这样不会有问题么。如果每次只移动一个word会有意义么?

建立模型

建立模型在tensorflow里就是构建graph的过程,在这个例子中提供了三种不同规模网络的图配置:

  • 小型网络,2个LSTM层,展开大小是20,每层包含了200个隐藏节点.
  • 中型网络,2个LSTM层,展开大小是35,每层包含了650个隐藏节点
  • 大型网络,2个LSTM层,展开大小是35,每层包含了1500个隐藏节点

值得注意的是,小型网络在训练时是没有dropout的,中型网络是0.5,大型网络是0.65(对应keep_prob是0.35),这反映出越多的隐藏节点过拟合的可能性也越高。

PS:以下介绍代码的顺序和代码原始顺序稍微调整了一下,以便于更好的理解

接下来要主要看两个类PTBInputPTBModel,前者整理好输入数据,后者定义了模型本身。首先看PTBInput

1
2
3
4
5
6
7
8
class PTBInput(object):
  """The input data."""
  def __init__(self, config, data, name=None):
    self.batch_size = batch_size = config.batch_size
    self.num_steps = num_steps = config.num_steps
    self.epoch_size = ((len(data) // batch_size) - 1) // num_steps
    self.input_data, self.targets = reader.ptb_producer(
        data, batch_size, num_steps, name=name)

这个类有5个fields:

  • batch_size - batch大小,这个就是从配置中读取出来的了
  • num_steps - 展开长度,这个也是从配置中读取出来的
  • epoch_size - 迭代次数,算法和数据处理中的算法是一致的
  • input_data - 输入数据,就是x了,一个shape = (batch_size, num_steps)的tensor
  • targets - 输出数据,就是y了,一个shape = (batch_size, num_steps)的tensor

以上很好理解,接下来就是构建模型的过程。首先我们先概括的看一下构建的模型是什么结构:

  • 首先对输入的数据进行word embedding
  • 输入多层LSTM网络
  • Softmax输出层

Word embedding的目的不必多说,通过将一个个离散的单词变成一个低维向量使其具有更强的语义特征。譬如v(长江)和v(黄河)的语义距离会比v(长江)和v(美国)的距离更近,而one-hot编码是没有这样的信息的。大名鼎鼎的word2vec就是一种embedding方法,那我们回到例子里看:

1
2
3
4
 with tf.device("/cpu:0"):
      embedding = tf.get_variable(
          "embedding", [vocab_size, size], dtype=data_type())
      inputs = tf.nn.embedding_lookup(embedding, input_.input_data)

首先embedding lookup指定在cpu上运行,因为目前tensorflow还不支持在gpu上做embedding lookup。这段代码里有几个关键参数:

  • vocab_size - 单词词表大小
  • size - embedding之后的向量长度,这里的size就是隐藏节点的个数。为了计算方便似的embedding之后的向量和第一层LSTM隐藏节点个数匹配。

embedding_lookup函数的第一个参数是params,除了例子中的方式其有更一般化的用法,只要是一个Tensor List就可以。因此,理论上每一个word在embedding之后的不一定是向量,也可能是二维矩阵或者之类的,只是没听说这么用的。

因为是训练一个word embedding模型,因此这里直接获得了一个空的embedding变量,注意get_variable有一个trainable参数表示是否可以被训练,默认是true。

接下来是构建多层LSTM网络:

1
2
3
4
5
6
7
8
9
10
# Slightly better results can be obtained with forget gate biases
# initialized to 1 but the hyperparameters of the model would need to be
# different than reported in the paper.
lstm_cell = tf.nn.rnn_cell.BasicLSTMCell(size, forget_bias=0.0, state_is_tuple=True)
if is_training and config.keep_prob < 1:
  lstm_cell = tf.nn.rnn_cell.DropoutWrapper(
      lstm_cell, output_keep_prob=config.keep_prob)
cell = tf.nn.rnn_cell.MultiRNNCell([lstm_cell] * config.num_layers, state_is_tuple=True)

self._initial_state = cell.zero_state(batch_size, data_type())

#4 这一行创建了一个Size大小的LSTM单元。关于Tensorflow的几种RNN实现后文补充说明中再介绍,这里创建了一个非常基本简单的LSTM层。state_is_tuple这个参数表示输出结果结构,因为LSTM网络的输出的是output + state,在过去版本的API里output和state被连接在了一起,考虑到性能问题新版API把这个结果分开来返回,因此state_is_tuple设置为了true,在后续某个版本里state_is_tuple为false将不被支持。

#5 ~ 6 这一段增加了dropout,关于dropout部分下面详述。注意,只有在训练的时候才会使用dropout。

#8 这一段就复制刚刚创建好的一层变成N层,N来源与配置。MultiRNNCell方法封装了连接多个RNN层的操作。

#10 这一段就是创建了一个初始化的状态(全0状态)

着重说明一下dropout的问题。dropout存在的目的是选择性(按照一定的概率)丢弃一部分的输入或者输出,防止在训练的时候造成过拟合的情况。本例中可以看到dropout是在多个RNN层之间进行的,这一点要和序列之间的dropout区分开来,多层RNN之间的dropout的本质是在某t0时刻,输入了x和state之后,有某层RNN向后一层RNN在传递信息的时候做dropout,无论多少层都是在处理t0时刻的数据。而从t0时刻得到的state和t1时刻的x输入模型时不做dropout,也就是在时间维度上并没有dropout,上一时刻的信息完整的带入了下一时刻。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if is_training and config.keep_prob < 1:
      inputs = tf.nn.dropout(inputs, config.keep_prob)

# Simplified version of tensorflow.models.rnn.rnn.py's rnn().
# This builds an unrolled LSTM for tutorial purposes only.
# In general, use the rnn() or state_saving_rnn() from rnn.py.
#
# The alternative version of the code below is:
#
# inputs = tf.unstack(inputs, num=num_steps, axis=1)
# outputs, state = tf.nn.rnn(cell, inputs, initial_state=self._initial_state)
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(1, outputs), [-1, size])

这一段比较重要,例子中为了更好的说明RNN网络在tensorflow中的工作流程,没有使用内建的函数来运行网络而是写了一个简单的版本来说明,注释写的很清楚。

#1 ~ 2 这两行如上文介绍的dropout,除了在RNN网络之间进行dropout,在输入网络之前(word embedding之后)也进行一次dropout。

#12 ~ 19 这一大段实际上是tf.nn.rnn的简化版本,其目的都是运行RNN网络。tensorflow内置的RNN网络运行函数有多种,同样在补充说明中介绍。这一段代码首先去循环序列,循环num_steps次,也就是展开之后RNN网络的长度,每一个时刻都将该时刻的x和上一时刻的state输入模型,得到模型的output和本次计算之后的state。这里有几点:

  • t0时刻输入的state是初始化的state
  • 在处理这个序列的时候,所有的变量都是复用的
    • 循环内state是一个shape = (batch_size, size)的Tensor
    • 循环内output是一个shape = (batch_size, size)的Tensor
  • 处理完的结果保存在了outputs里,这样outputs可以看做是一个shape = (num_steps, batch_size, size)的Tensor
  • output将outputs变换了一下,首先按照每个向量的第1维(0表示顺序连接)的元素连接,然后变换到rank = 2, shape = (-1, size)的Tensor(-1表示不限定长度)。所以这里的output最终是一个shape = (batch_size * num_steps, size)的Tensor

关于tf.concat在多说一句,以下举一个例子:

1
2
3
4
5
6
7
8
[
  [a, b, c],
  [d, e, f]
]
[
  [g, h, i],
  [j, k, l]
]

a, b .. l可能是个0 ~ N维的数据,执行tf.concat(1, ..)之后得到:

1
2
3
4
[
  [a, b, c, g, h, i]
  [d, e, f, j, k, l]
]

接下来就是Softmax层以及loss function,输入的并非softmax之后的结果,而是进行softmax之前的值,损失函数内部对softmax操作和损失计算进行了优化节省了一些运算量。

1
2
3
4
5
6
7
8
9
10
softmax_w = tf.get_variable(
        "softmax_w", [size, vocab_size], dtype=data_type())
    softmax_b = tf.get_variable("softmax_b", [vocab_size], dtype=data_type())
    logits = tf.matmul(output, softmax_w) + softmax_b
    loss = tf.nn.seq2seq.sequence_loss_by_example(
        [logits],
        [tf.reshape(input_.targets, [-1])],
        [tf.ones([batch_size * num_steps], dtype=data_type())])
    self._cost = cost = tf.reduce_sum(loss) / batch_size
    self._final_state = state

#1 ~ 4 这段代码计算出每个单词的权重,wx + b其中w是一个shape=(size, vocab_size)的Tensor,b是一个shape=(vocab_size)的Tensor,还记得之前的文章里写的计算规则了么?

#5 ~ 9 这一段就是使用内建的函数计算损失了,这个函数是一个加权的交叉熵损失函数,这里权重都是1了。logits每行都是对应于一个batch的一个时刻在各个单词上的权重。最后cost就是loss之和平均一下。

模型的构建和优化方法的定义到这里就完成了,接下来就是运行和测试模型的过程。

运行和测试模型

接下来这一段介绍了例子中完整的运行一次迭代的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def run_epoch(session, model, eval_op=None, verbose=False):
  """Runs the model on the given data."""
  start_time = time.time()
  costs = 0.0
  iters = 0
  state = session.run(model.initial_state)

  fetches = {
      "cost": model.cost,
      "final_state": model.final_state,
  }
  if eval_op is not None:
    fetches["eval_op"] = eval_op

  for step in range(model.input.epoch_size):
    feed_dict = {}
    for i, (c, h) in enumerate(model.initial_state):
      feed_dict[c] = state[i].c
      feed_dict[h] = state[i].h

    vals = session.run(fetches, feed_dict)
    cost = vals["cost"]
    state = vals["final_state"]

    costs += cost
    iters += model.input.num_steps

    if verbose and step % (model.input.epoch_size // 10) == 10:
      print("%.3f perplexity: %.3f speed: %.0f wps" %
            (step * 1.0 / model.input.epoch_size, np.exp(costs / iters),
             iters * model.input.batch_size / (time.time() - start_time)))

  return np.exp(costs / iters)

这段代码比较好理解,大家回忆一下epoch_size的计算方法,这里循环epoch_size次把一个batch都处理完。这里注意一点每次循环迭代的时候,initial_state被上一次输出的final_state代替,第一次循环的话就是它自己。

本文由作者按照 CC BY 4.0 进行授权