Kerasで基本的なRNN (LSTM) を試してみる

# はじめに

Keras (TensorFlowバックエンド) のRNN (LSTM) を超速で試してみます。
時系列データを入力に取って学習するアレですね。
TensorFlowではモデル定義以外のところでいろいろコーディングが必要なので、Kerasを使って本質的な部分に集中したいと思います。

# 動作環境

- Linux (CentOS 7)
- Python 3.6.8
- TensorFlow 1.5.0 / Keras 2.2.4
    - 使っているCPUの関係で、TensorFlowは最新版だと動かないのでバージョンを落としています。

Python 3をインストールした後、以下のコマンドでTensorFlow / Kerasをセットアップします。

```terminal
pip3 install tensorflow keras     # CPU版
pip3 install tensorflow-gpu keras # GPU版
```

# 問題設定

今回の題材は「{0.0, 1.0}からなる列の総和を出力するモデル」とします。
例えば ``[1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0]`` が入力であれば、``8.0`` が出力されるようにします。
この問題設定は以下から拝借しました。
[TensorFlowのRNNを基本的なモデルで試す - Qiita](https://qiita.com/yukiB/items/f6314d2861fc8d9b739f)

# Kerasでモデルを組む

KerasでのLSTMレイヤーの使い方は以下を参考にしています。
[KerasでRNN(LSTM)を試してみる - Qiita](https://qiita.com/sasayabaku/items/b7872a3b8acc7d6261bf)

各参考ページのコードの大部分を拝借＆自分なりに改良していますが、自分の理解のために、なるべくコメントを入れて何をしたいかわかるようにしています。

## 固定長の系列を入力する場合

まずは固定長の入力がされる場合を考えます。
以下のコードでは、長さがちょうど10の{0.0, 1.0}系列が入力されたときに、その総和を出力するモデルを学習しています。

```python:rnn.py
#!/usr/bin/env python3

import tensorflow as tf
from keras.models import Sequential
from keras.layers.core import Dense
from keras.layers.recurrent import LSTM
from keras.optimizers import Adam
import numpy as np
import random

input_dim = 1                # 入力データの次元数：実数値1個なので1を指定
output_dim = 1               # 出力データの次元数：同上
num_hidden_units = 128       # 隠れ層のユニット数
len_sequence = 10            # 時系列の長さ
batch_size = 300             # ミニバッチサイズ
num_of_training_epochs = 100 # 学習エポック数
learning_rate = 0.001        # 学習率
num_training_samples = 1000  # 学習データのサンプル数

# データを作成
def create_data(nb_of_samples, sequence_len):
    # 乱数で {0.0, 1.0} の列を指定された個数だけ生成する
    X = np.random.randint(0, 2, (nb_of_samples, sequence_len)).astype("float32")
    # 各行の総和を正解ラベルとする
    t = np.sum(X, axis=1)
    # LSTMに与える入力は (サンプル, 時刻, 特徴量の次元) の3次元になる。
    return X.reshape((nb_of_samples, sequence_len, 1)), t

# 乱数シードを固定値で初期化
random.seed(0)
np.random.seed(0)
tf.set_random_seed(0)

X, t = create_data(num_training_samples, len_sequence)

# モデル構築
model = Sequential()
model.add(LSTM(
    num_hidden_units,
    input_shape=(len_sequence, input_dim),
    return_sequences=False))
model.add(Dense(output_dim))
model.compile(loss="mean_squared_error", optimizer=Adam(lr=learning_rate))
model.summary()

# 学習
model.fit(
    X, t,
    batch_size=batch_size,
    epochs=num_of_training_epochs,
    validation_split=0.1
)

# 予測
# (サンプル, 時刻, 特徴量の次元) の3次元の入力を与える。
test = np.array([1, 1, 1, 1, 0, 1, 1, 1, 0, 1]).astype("float32").reshape((1, 10, 1))
print(model.predict(test)) # [[7.7854743]]
```

ポイントはデータの作り方だと思います。
時系列データを与えることになりますので、LSTMレイヤーに入力される次元数 ``input_shape`` がDNNの場合より1次元増えて、(時刻, 特徴量の次元) の2次元になります。
同様に、学習データの次元数も1つ増えて、(サンプル, 時刻, 特徴量の次元) の3次元で作成することになります。

## 可変長の系列を入力する場合 (1)

今度は、入力される系列の長さが可変の場合を考えます。
自然言語処理の言語モデルなどを考えると、単語列や文字列の長さはどうしても変わり得るものです。いつも同じ長さの入力しかできないのでは具合が悪いでしょう。

Maskingレイヤーを用いて、時系列の入力に対してマスキング（データをスキップする）処理を実装することで、可変長の入力を取り扱うことができるようになります。
長さが異なるデータを学習データ (``numpy.array``) として扱う際には、バッチ処理の都合上、系列の長さ（2次元目）は最大長に合わせることになります。このとき、最大長に満たない部分に特別な値を入れておくことで、パディング（長さ合わせ）を表すことができます。例えば、この「特別な値」を ``-1.0`` と決めておき

```
[[ 1.  0.  1.  1.  0.  1.  0. -1. -1. -1.]
 [ 0.  1.  1.  1.  1.  0. -1. -1. -1. -1.]
 [ 0.  1.  0.  1.  1.  0. -1. -1. -1. -1.]
 [ 0.  0.  1.  0.  0. -1. -1. -1. -1. -1.]
 [ 0.  1.  1.  1.  1. -1. -1. -1. -1. -1.]
 [ 1.  0.  1.  0.  0.  0.  0. -1. -1. -1.]
 [ 1.  0.  1.  1.  0.  0.  1.  1.  1.  0.]
 [ 0.  1.  0. -1. -1. -1. -1. -1. -1. -1.]
 [ 1.  0.  0.  0.  1.  0.  1. -1. -1. -1.]
 [ 1.  1.  1.  1.  1.  1. -1. -1. -1. -1.]]
```

といったデータを作れば、各行が順に長さ7の入力、長さ6の入力、… を表すことになります。
（実際に学習・予測に使うときには、3次元にreshapeする必要がありますが）

このような学習データを乱数で作成し、予測を試すコードは以下のようになります。

```python:rnn_dynamic_1.py
#!/usr/bin/env python3

import tensorflow as tf
from keras.models import Sequential
from keras.layers import Dense, Masking
from keras.layers.recurrent import LSTM
from keras.optimizers import Adam
import numpy as np
import random

input_dim = 1                # 入力データの次元数：実数値1個なので1を指定
output_dim = 1               # 出力データの次元数：同上
num_hidden_units = 128       # 隠れ層のユニット数
batch_size = 300             # ミニバッチサイズ
num_of_training_epochs = 100 # 学習エポック数
learning_rate = 0.001        # 学習率
num_training_samples = 1000  # 学習データのサンプル数

# データを作成
def create_data(nb_of_samples):
    # 長さを対数正規分布に従って決める
    leng = np.around(np.random.lognormal(np.log(5.0), 0.5, (nb_of_samples, 1))).astype("int")
    max_sequence_len = leng.max()
    # 乱数で {0.0, 1.0} の列を生成する
    X = np.random.randint(0, 2, (nb_of_samples, max_sequence_len)).astype("float32")
    # 長さを超えた部分を-1.0に置き換える
    X[np.arange(max_sequence_len).reshape((1, -1)) >= leng] = -1.0
    # 各行の-1.0を除いた総和を正解ラベルとする
    t = np.ma.array(X, mask=(X == -1.0)).sum(axis=1)
    # LSTMに与える入力は (サンプル, 時刻, 特徴量の次元) の3次元になる。
    return X.reshape((nb_of_samples, max_sequence_len, 1)), t

# 乱数シードを固定値で初期化
random.seed(0)
np.random.seed(0)
tf.set_random_seed(0)

X, t = create_data(num_training_samples)

# モデル構築
model = Sequential()
# パディングの値を指定してMaskingレイヤーを作成する
model.add(Masking(
    input_shape=(None, input_dim),
    mask_value=-1.0))
model.add(LSTM(
    num_hidden_units,
    return_sequences=False))
model.add(Dense(output_dim))
model.compile(loss="mean_squared_error", optimizer=Adam(lr=learning_rate))
model.summary()

# 学習
model.fit(
    X, t,
    batch_size=batch_size,
    epochs=num_of_training_epochs,
    validation_split=0.1
)

# 予測
# 任意の長さの入力を受け付ける
test = np.array([1, 1, 1, 0, 1, 0, 1]).astype("float32")
# (a) 長さを変えずに入力
print(model.predict(test.reshape((1, -1, 1))))                                                    # [[4.9639335]]
# (b) 後ろに適当な数の-1.0を追加して入力
print(model.predict(np.pad(test, (0, 10), "constant", constant_values=-1.0).reshape((1, -1, 1)))) # [[4.9639335]]
```

最後の2つのprint文が同じ結果を出力することから、``-1.0``がパディングとして特別扱いされていることが分かります。

## 可変長の系列を入力する場合 (2)

入力として取り得る値が有限個であれば、Embeddingレイヤーを使うこともできます。
名前の通り、本来はword2vecのような次元圧縮（埋め込み）のタスクに用いることが想定されているようです。
[Embeddingレイヤー - Keras Documentation](https://keras.io/ja/layers/embeddings/)
[Using pre-trained word embeddings in a Keras model](https://blog.keras.io/using-pre-trained-word-embeddings-in-a-keras-model.html)

以下のページが参考になりました。
[KerasでLSTMに可変長系列を入力するときはEmbedding(mask_zero=True)を使う - Qiita](https://qiita.com/hrappuccino/items/f66abebe60f8ea7826d5)

可変長の学習データを扱う際の注意は、先ほどと同じです。
また、入力データは非負整数（単語IDなどをイメージしてください）になり、パディングを表す値として ID: 0 が予約されます。ID: 0 はデータとしては使用できないので、``0.0`` を ID: 1 に、``1.0`` を ID: 2 に対応させます。そして、この対応関係をEmbeddingレイヤーに与える埋め込み行列（IDからN次元空間への変換行列）で表現します。

コードは以下のようになります。

```python:rnn_dynamic_2.py
#!/usr/bin/env python3

import tensorflow as tf
from keras.models import Sequential
from keras.layers import Dense
from keras.layers.recurrent import LSTM
from keras.layers.embeddings import Embedding
from keras.optimizers import Adam
import numpy as np
import random

input_dim = 1                # 入力データの次元数：実数値1個なので1を指定
output_dim = 1               # 出力データの次元数：同上
num_hidden_units = 128       # 隠れ層のユニット数
batch_size = 300             # ミニバッチサイズ
num_of_training_epochs = 100 # 学習エポック数
learning_rate = 0.001        # 学習率
num_training_samples = 1000  # 学習データのサンプル数

# データを作成
def create_data(nb_of_samples):
    # 長さを対数正規分布に従って決める
    leng = np.around(np.random.lognormal(np.log(5.0), 0.5, (nb_of_samples, 1))).astype("int")
    max_sequence_len = leng.max()
    # 乱数で {1, 2} の列を生成する
    X = np.random.randint(1, 3, (nb_of_samples, max_sequence_len))
    # 長さを超えた部分を0に置き換える
    X[np.arange(max_sequence_len).reshape((1, -1)) >= leng] = 0
    # 各行の2の個数を正解ラベルとする
    t = np.sum(X == 2, axis=1).astype("float32")
    # LSTM (Embedding) に与える入力は (サンプル, 時刻) の2次元になる。
    return X, t

# 乱数シードを固定値で初期化
random.seed(0)
np.random.seed(0)
tf.set_random_seed(0)

X, t = create_data(num_training_samples)

# Embeddingの埋め込み行列を指定する
# https://blog.keras.io/using-pre-trained-word-embeddings-in-a-keras-model.html
embedding_matrix = np.array([[0.0], [0.0], [1.0]], dtype="float32")
# モデル構築
model = Sequential()
# Embeddingによりベクトルを変換する
model.add(Embedding(
    input_dim=embedding_matrix.shape[0], # 入力として取り得るカテゴリ数（パディングの0を含む）
    output_dim=input_dim,                # 出力ユニット数（本来の特徴量の次元数）
    weights=[embedding_matrix],          # 埋め込み行列を指定
    trainable=False,                     # 埋め込み行列を固定（学習時に更新しない）
    mask_zero=True))                     # 0をパディング用に特別扱いする
model.add(LSTM(
    num_hidden_units,
    return_sequences=False))
model.add(Dense(output_dim, activation="linear"))
model.compile(loss="mean_squared_error", optimizer=Adam(lr=learning_rate))
model.summary()

# 学習
model.fit(
    X, t,
    batch_size=batch_size,
    epochs=num_of_training_epochs,
    validation_split=0.1
)

# 予測
# 任意の長さの入力を受け付ける
test = np.array([1, 1, 1, 0, 1, 0, 1])
# {0, 1}を{1, 2}に変換してモデルに入力
# (a) 長さを変えずに入力
print(model.predict((test + 1).reshape((1, -1))))                              # [[4.88129]]
# (b) 後ろに適当な数の0を追加して入力
print(model.predict(np.pad((test + 1), (0, 10), "constant").reshape((1, -1)))) # [[4.88129]]
```

最後の2つのprint文が同じ結果を出力することから、``0``がパディングとして特別扱いされていることが分かります。

Embeddingレイヤーは入力される特徴量を1次元（単語などのID）と想定しているので、データ作成部分が今までとは少し変わっています。今までは入力データを実数値 (float32) で与えていましたが、今回は入力データが整数値になっています。（LSTMレイヤーに与えられる特徴量は、これまでと同様に実数値になりますが）

ここまでできると、自然言語処理で何か作れるかもしれないと思えてきます。（私だけ？）