TensorFlow の RNN チュートリアルやってみた

自分用のメモ／学習を兼ねて、TensorFlow の RNN のチュートリアルを一行ずつみながらやってみる。
LSTM とはなんぞや、とか、そもそもの TensorFlow の使い方とかは、チュートリアル中にあるので割愛。
チュートリアルで用いているコードの内容の解説だけをおこなう。
コードのインデントが2で気持ち悪いとか、この関数 `deprecated` なんだけど💢といった苦情は Google 社へお願い致します。

対象としているチュートリアルは、 Language and Sequemce Processing の下にある、[Recurrent Neural Networks](https://www.tensorflow.org/tutorials/recurrent/) というページ。

# データ準備
まずはチュートリアル中で使用するデータ／コードのDLから。

## コード
TensorFlow 本体にはチュートリアルのコードが一部しか含まれていないため、別途DLが必要。
TensorFlow 公式の [models](https://github.com/tensorflow/models) というレポジトリにコードが含まれているので、それをクローンする

```bash
git clone https://github.com/tensorflow/models.git
```

クローンしたレポジトリの、 `models/tutorials/rnn/ptb` の下に、 `ptb_word_lm.py` と `reader.py` というファイルがあるはず。

## データ
チュートリアルに従って DL & 展開

```bash
wget http://www.fit.vutbr.cz/~imikolov/rnnlm/simple-examples.tgz
tar xvfz simple-examples.tgz
```

展開したディレクトリ中の、`data/` 以下が今回使用するデータ。

# データの形式
展開したデータを見てみる。

```bash
$ head ptb.train.txt
 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 <unk> N years old will join the board as a nonexecutive director nov. N
 mr. <unk> is chairman of <unk> n.v. the dutch publishing group
 rudolph <unk> 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
 the asbestos fiber <unk> is unusually <unk> once it enters the <unk> with even brief exposures to it causing symptoms that show up decades later researchers said
 <unk> inc. the unit of new york-based <unk> corp. that makes kent cigarettes stopped using <unk> in its <unk> cigarette filters in N
 although preliminary findings were reported more than a year ago the latest results appear in today 's new england journal of medicine a forum likely to bring new attention to the problem
 a <unk> <unk> said this is an old story
 we 're talking about years ago before anyone heard of asbestos having any questionable properties
```

事前に未知語を `<unk>` という特殊記号で置き換え済みの英文。
検証用データ `ptb.valid.txt`、テスト用データ `ptb.test.txt` も同様の形式。

# チュートリアル開始
DLしたチュートリアルコードは `python ptb_word_lm.py --data_path=/tmp/simple-examples/data/ --model small` (`data_path` は適宜読み替えてください) で走らせることができるようになっているが、一行ずつ解説するのには都合が悪いため、`main` 関数の中身をバラバラにして少しずつ実行していくことにする。
*ここからは、チュートリアルのコードを一行ずつ実行するために、処理内容は変えずにコードを一部改変していることに注意してください。*

## データの読み込み
まず行われるのはデータの読み込み処理。 `main` 関数の一番最初の部分は次のようになっている

```python
raw_data = reader.ptb_raw_data(FLAGS.data_path)
train_data, valid_data, test_data, _ = raw_data
```

では、 `reader.ptb_raw_data()` 関数の中身を見ていこう。

```python
def ptb_raw_data(data_path=None):
  """Load PTB raw data from data directory "data_path".

  Reads PTB text files, converts strings to integer ids,
  and performs mini-batching of the inputs.

  The PTB dataset comes from Tomas Mikolov's webpage:

  http://www.fit.vutbr.cz/~imikolov/rnnlm/simple-examples.tgz

  Args:
    data_path: string path to the directory where simple-examples.tgz has
      been extracted.

  Returns:
    tuple (train_data, valid_data, test_data, vocabulary)
    where each of the data objects can be passed to PTBIterator.
  """

  train_path = os.path.join(data_path, "ptb.train.txt")
  valid_path = os.path.join(data_path, "ptb.valid.txt")
  test_path = os.path.join(data_path, "ptb.test.txt")

  word_to_id = _build_vocab(train_path)
  train_data = _file_to_word_ids(train_path, word_to_id)
  valid_data = _file_to_word_ids(valid_path, word_to_id)
  test_data = _file_to_word_ids(test_path, word_to_id)
  vocabulary = len(word_to_id)
  return train_data, valid_data, test_data, vocabulary
```

`ptb_raw_data()` は、まず訓練データから語彙 (`word_to_id`) を構築 (`_build_vocab()`) し、それを用いて訓練／検証／テストデータをインデックス化 (`_file_to_word_ids()`) している。

`_build_vocab()` は次の通り：

```python
def _read_words(filename):
  with tf.gfile.GFile(filename, "r") as f:
    return f.read().decode("utf-8").replace("\n", "<eos>").split()


def _build_vocab(filename):
  data = _read_words(filename)  # データを読み込み、スペースで分割する (つまり単語列を得る)

  counter = collections.Counter(data)  # 単語を数え上げる
  count_pairs = sorted(counter.items(), key=lambda x: (-x[1], x[0]))  # 登場回数の大きい順にソート

  words, _ = list(zip(*count_pairs))  # 単語を登場回数が大きな順に取り出して
  word_to_id = dict(zip(words, range(len(words))))  # インデックス化する

  return word_to_id
```

処理内容はコード中に日本語コメントで注釈しました。
最終的に帰ってくる `word_to_id` は、各単語をインデックスに変換する辞書です (`{'the': 0, 'a': 1, ..., 'hoge': 133, ...}`)。

次に `_file_to_word_ids()`：

```python
def _file_to_word_ids(filename, word_to_id):
  data = _read_words(filename)
  return [word_to_id[word] for word in data if word in word_to_id]
```

これは非常に単純で、読み込んだ単語列をインデックス化したリストを返しているだけです。
ただし、訓練データから構築した語彙 `word_to_id` 中に含まれない単語は無視しています。

結局、欲しかった `train_data, valid_data, test_data` はそれぞれ最終的に `[102, 14, 24, 32, 752, 381, ...]` 形式のリストとなります。元データの単語を、単にそのインデックスで置き換えたリストです。

## ネットワーク構築

`main` 関数の続き：

```python
with tf.name_scope("Train"):
  train_input = PTBInput(config=config, data=train_data, name="TrainInput")
  with tf.variable_scope("Model", reuse=None, initializer=initializer):
    m = PTBModel(is_training=True, config=config, input_=train_input)
  tf.contrib.deprecated.scalar_summary("Training Loss", m.cost)
  tf.contrib.deprecated.scalar_summary("Learning Rate", m.lr)

with tf.name_scope("Valid"):
  valid_input = PTBInput(config=config, data=valid_data, name="ValidInput")
  with tf.variable_scope("Model", reuse=True, initializer=initializer):
    mvalid = PTBModel(is_training=False, config=config, input_=valid_input)
  tf.contrib.deprecated.scalar_summary("Validation Loss", mvalid.cost)

with tf.name_scope("Test"):
  test_input = PTBInput(config=eval_config, data=test_data, name="TestInput")
  with tf.variable_scope("Model", reuse=True, initializer=initializer):
    mtest = PTBModel(is_training=False, config=eval_config,
                     input_=test_input)
```

Train/Valid/Test でそれぞれ同じようなことをおこなっています。
まず `PTBInput` (これは単なる構造体です) にデータを詰めて、 `PTBModel` (ネットワーク本体) に渡し、サマリを出力します。
順番に見ていきます。

### PTBInput
```python
class PTBInput(object):
  """The input data."""

  def __init__(self, config, data, name=None):
    self.batch_size = batch_size = config.batch_size  # 「1エポックあたりに使うデータの数」
    self.num_steps = num_steps = config.num_steps  # 「LSTM の unroll の長さ」
    self.epoch_size = ((len(data) // batch_size) - 1) // num_steps  # 「batch_size x num_steps を何回使えるか」＝「データが一回りするまでの回転数」＝「エポックあたりの標本数」
    self.input_data, self.targets = reader.ptb_producer(
        data, batch_size, num_steps, name=name)
```

「LSTM の unroll」とは、LSTM の時系列を数ステップぶん明示的に書き下すことで、時間方向への依存をなくして「普通の誤差逆伝播法」を使えるようにすることを言います。つまり、訓練時には `num_steps` の長さの単語をひとつの「標本」として、 `batch_size` ぶんの標本を一度に RNN に入力して誤差を計算することになります。

さて、 最後の部分で `reader.ptb_producer()` が何をやっているのかを追跡しないとなりません。

<img width="1004" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/62450/522a90c6-a725-3716-4aa8-f7bec9d09bbb.png">

```python
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)  # 扱いやすいようにビルトインの List から Tensor に変換

    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])  # 1次元リストだった raw_data を、batch_size x batch_len の行列に整形

    epoch_size = (batch_len - 1) // num_steps  # 1エポック (データの一回り) の大きさ
    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()  # [0, 1, .., epoch_size-1] という整数を順ぐりに無限生成するイテレータ
    x = tf.strided_slice(data, [0, i * num_steps],
                         [batch_size, (i + 1) * num_steps])  # この使われ方の strided_slice は、data[0:batch_size, i*num_steps:(i+1)*num_steps] だと思って良い
    x.set_shape([batch_size, num_steps])
    y = tf.strided_slice(data, [0, i * num_steps + 1],
                         [batch_size, (i + 1) * num_steps + 1])  # 正解 y は x の次に来る単語なので、1を足してスライスを右に一つずらす
    y.set_shape([batch_size, num_steps])
    return x, y
```

注釈はコード中に日本語で入れましたが、おそらく上の図を見たほうが、ここで何をしたいのかは明らかだと思います。

### PTBModel

`main` 関数に戻ります。作成したデータをモデルに投入します。`PTBModel` は以下のとおりです：

```python
class PTBModel(object):
  """The PTB model."""

  def __init__(self, is_training, config, input_):
    self._input = input_

    batch_size = input_.batch_size
    num_steps = input_.num_steps
    size = config.hidden_size
    vocab_size = config.vocab_size

    # 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.contrib.rnn.BasicLSTMCell(
        size, forget_bias=0.0, state_is_tuple=True)  # LSTM Cell を構築
    if is_training and config.keep_prob < 1:
      lstm_cell = tf.contrib.rnn.DropoutWrapper(
          lstm_cell, output_keep_prob=config.keep_prob)  # 訓練時は Dropout を設定
    cell = tf.contrib.rnn.MultiRNNCell(
        [lstm_cell] * config.num_layers, state_is_tuple=True)  # LSTM を積み上げる

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

    with tf.device("/cpu:0"):
      embedding = tf.get_variable(
          "embedding", [vocab_size, size], dtype=data_type())  # LSTM本体に渡す前に、インデックス化された単語を embedding しておく
      inputs = tf.nn.embedding_lookup(embedding, input_.input_data)

    if is_training and config.keep_prob < 1:
      inputs = tf.nn.dropout(inputs, config.keep_prob)

    # Simplified version of models/tutorials/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)
    # (意訳)：このチュートリアルでは LSTM を自力で unroll するけど、
    # 実用上は tf.unstack() -> tf.nn.rnn() を使えばいいよ。
    outputs = []
    state = self._initial_state
    with tf.variable_scope("RNN"):
      for time_step in range(num_steps):
        # time_step==0 のときだけ変数を作成して、それ以外では使いまわす。
        # unroll するからパラメータが増えたようにみえるけど、本当は同じパラメータなのだから当然同じものである。
        if time_step > 0: tf.get_variable_scope().reuse_variables()
        (cell_output, state) = cell(inputs[:, time_step, :], state)  # 積み上げた LSTM から時系列のデータを順番に取り出す (unrolling)
        outputs.append(cell_output)

    output = tf.reshape(tf.concat_v2(outputs, 1), [-1, size])  # 取り出したデータを合体して整形
    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.contrib.legacy_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  # batch_size ぶん一度に計算したので割る
    self._final_state = state  # 時系列の最後の内部状態は、次のバッチの初期状態となるので取っておく

    if not is_training:  # 検証／テスト時はここでおしまい  以下は訓練時のみ
      return

    self._lr = tf.Variable(0.0, trainable=False)  # lr: learning rate
    tvars = tf.trainable_variables()
    grads, _ = tf.clip_by_global_norm(tf.gradients(cost, tvars),
                                      config.max_grad_norm)  # 勾配をクリップしないとうまく学習できないことがわかっている
    optimizer = tf.train.GradientDescentOptimizer(self._lr)  # SGD で最適化
    self._train_op = optimizer.apply_gradients(
        zip(grads, tvars),
        global_step=tf.contrib.framework.get_or_create_global_step())

    self._new_lr = tf.placeholder(
        tf.float32, shape=[], name="new_learning_rate")  # 学習率を外から更新できるようにしておく
    self._lr_update = tf.assign(self._lr, self._new_lr)
```

長いですね。めげずに読んでいきましょう。
この `PTBModel` が構築できれば、あとは実際にパラメータ (重み) を計算をするだけです。

## パラメータ (重み) 計算

例のごとく `main` 関数に戻ると、ようやく計算フェイズです。

```python
sv = tf.train.Supervisor(logdir=FLAGS.save_path)
with sv.managed_session() as session:
  for i in range(config.max_max_epoch):
    lr_decay = config.lr_decay ** max(i + 1 - config.max_epoch, 0.0)
    m.assign_lr(session, config.learning_rate * lr_decay)  # 学習率は指数的に減らしていく

    print("Epoch: %d Learning rate: %.3f" % (i + 1, session.run(m.lr)))
    train_perplexity = run_epoch(session, m, eval_op=m.train_op,
                                 verbose=True)  # 計算の本体はこいつ
    print("Epoch: %d Train Perplexity: %.3f" % (i + 1, train_perplexity))
    valid_perplexity = run_epoch(session, mvalid)
    print("Epoch: %d Valid Perplexity: %.3f" % (i + 1, valid_perplexity))

  test_perplexity = run_epoch(session, mtest)
  print("Test Perplexity: %.3f" % test_perplexity)

  if FLAGS.save_path:
    print("Saving model to %s." % FLAGS.save_path)
    sv.saver.save(session, FLAGS.save_path, global_step=sv.global_step)
```

さて、計算の本体である `run_epoch()` を読む必要があります。そんなに長くないです：

```python
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)  # 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)  # ほしいのは、fetches に入っている cost と final_state
    cost = vals["cost"]
    state = vals["final_state"]  # 次の初期状態は、今の最終状態

    costs += cost  # 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)
```

以上です。

# 回してみる

コードの読解は長々とかかりましたが、実際に動かすのはコマンド一発です。

```bash
python ptb_word_lm.py --data_path=/tmp/simple-examples/data/ --model small
```

GTX960 の場合、`small` モデルでは perplexity (エラー率だと思って良い [^1]) が (訓練、検証、テスト)＝(40.725, 119.601, 114.755)、かかった時間は30分ほどでした。
チュートリアルファイル中の説明によると、`small` モデルでは perplexity は (37.99, 121.39, 115.91) らしいので、外れていてはなさそうです。
`medium` はなんとか回りそうですが、`large` は (メモリ的にも時間的にも) 厳しそうです。

[^1]: perplexity は、誤解を恐れずに言えば「予測が『何個の候補からランダムに選ぶのと同じ』性能に到達したのか、その候補数」です。参考： http://www.slideshare.net/hoxo_m/perplexity