Help us understand the problem. What is going on with this article?

KerasのRNN (LSTM) で return_sequences=True を試してみる

More than 1 year has passed since last update.

はじめに

Kerasを使うとRNN (LSTM) なども手軽に試せて楽しいです。フレームワークごとに性能差などもあるのでしょうが、まずは取っつきやすいものからと思っています(データと最低限の設定を準備すれば使い始められる)。Kerasなら何とかいけるかも?と思っています。

RNN系のレイヤー全体にいえることとして、「return_sequences」というパラメータが存在するのですが
return_sequences=True って何?
という疑問が浮かびました。
Recurrentレイヤー - Keras Documentation

後で詳しく述べますが、時系列データに対する最終的な出力だけでなく、途中時点での出力を学習するための設定であるようです。

return_sequencesとは

return_sequences: 真理値.出力系列の最後の出力を返すか,完全な系列を返すか.

うん、分からん。
とはいえ、こちらのページを見るとなんとなく分かってきました。
言語モデルの性能が、実装により異なる件を解決する – programming-soda – Medium

バッチ型でシーケンシャル型と同じ内容を学習するなら、系列の長さ毎にデータを作る必要が出てきます。A,B,C,Dの4つがあったら、A, B, C=>Dだけでなく、A=>B、A,B=>Cも学習でテータに含まないといけないということです。このように対策しても精度が改善することは確認済みですが、この場合シーケンス分だけデータが増え学習に時間がかかります。隠れ層の計算を毎回最初からやっていることになり非効率的です。
そのため、各ステップの隠れ層の状態から予測する形にします。図にすると以下のような形です。これにより、隠れ層の再計算をすることなく、都度に予測する形の学習が可能になります。

return_sequences=True を指定すると、そのような学習ができるというのです。

具体的な問題で考える

前回の記事で取り上げた「{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 が出力されるようにします。
(前回の記事:Kerasで基本的なRNN (LSTM) を試してみる - Qiita

前回の記事では、入力列に対して、全体の総和だけを正解ラベルとして与えていました。
ところが、これは学習データに存在する長さの列に対してはそれなりにもっともな予測を出しますが、それ以外のものに対しては(長くても短くても)うまく動かないことがあります。
いろいろな長さの列を学習データに含めればよいですが、長さのバリエーションをカバーするため、用意しなければならないデータの量が増えてしまうのが難点。
前回の記事の「可変長の系列を入力する場合 (1)」では、実際にいろいろな長さの列を準備して学習を行っていました。

ここで、数列の総和だけでなく、先頭から途中までの和(部分和)もうまく学習に使えると、幸せになれるかもしれません。LSTMを含むRNNは時系列データを扱っていて、1つの値が入力されるごとに内部状態が変わっていきます。今までは、途中の内部状態については何も制御せず、最終的な内部状態だけを制御していた(最終的な出力さえ合っていれば計算過程はどうでもいい)のですが、普通に考えると、この内部状態は部分和(その時点までの総和)を表しているのが自然なはずです。そうなるように内部状態を制御すれば、学習データより短い数列に対しても、それなりにうまく総和を予測できるようになることが期待できます。

これまではラベルは総和を表す実数値1個だけだったのですが、今度はラベルは部分和となり、入力と同じ長さの数列になります。
具体例を見るほうが早いでしょう。Xを時系列の {0.0, 1.0} 列、tをラベルとすると、学習データは以下のように変わります。

Before

総和の 8.0 だけをラベルとして与えていました。

X t
1.0
1.0
1.0
1.0
0.0
1.0
1.0
1.0
0.0
1.0 8.0

After

総和の値だけでなく、Xの部分和(先頭からある要素までの総和)をラベルとして与えます。

X t
1.0 1.0
1.0 2.0
1.0 3.0
1.0 4.0
0.0 4.0
1.0 5.0
1.0 6.0
1.0 7.0
0.0 7.0
1.0 8.0

例えば、この系列を先頭から5つ取り出した

X t
1.0 1.0
1.0 2.0
1.0 3.0
1.0 4.0
0.0 4.0

も、長さ5の学習データとして意味のあるデータになります。大雑把に言えば、このような(先頭からある長さを取り出した)部分列も実質的に学習していることになる、と理解できそうです。

ここで面白いのは、LSTMレイヤーが元々持っている内部状態を制御するだけですので、LSTMレイヤーのモデルの複雑さ(パラメータ数)自体は変わらない点かと思います。学習データの件数やパラメータ数は増えていないのに、学習できる情報量が増えている?ような気がして、ちょっと戸惑ってしまいます。

プログラムで検証

同じ長さの列だけを学習データとして与えたときに、それより短い・長い列の総和をうまく予測できるかという課題にチャレンジします。
rnn_dynamic_before.py は、前回の記事の「可変長の系列を入力する場合 (1)」で取り上げた方法と同じで、ラベルには総和だけを与え、学習データを固定長に変えています。rnn_dynamic_after.py は、学習データのラベルを部分和に変更しています。

Before

rnn_dynamic_before.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       # 隠れ層のユニット数
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(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
)

# 予測
# (サンプル, 時刻, 特徴量の次元) の3次元の入力を与える。
# 学習データと同じ長さのデータ(総和は8.0)
test_10 = np.array([1, 1, 1, 1, 0, 1, 1, 1, 0, 1], dtype="float32").reshape((1, -1, 1))
print(model.predict(test_10)) # [[7.7854743]]
# 学習データより短いデータ(総和は4.0)
test_05 = np.array([1, 1, 1, 1, 0], dtype="float32").reshape((1, -1, 1))
print(model.predict(test_05)) # [[3.0897331]]
# 学習データより長いデータ(総和は12.0)
test_15 = np.array([1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0], dtype="float32").reshape((1, -1, 1))
print(model.predict(test_15)) # [[10.465068]]

学習データと同じ長さであれば、それなりに近い値?(一応四捨五入すると期待の結果になる)が予測されますが、短いデータや長いデータだと誤差が大きくなってしまいます。短い方に関しては「答えは合ってるけど途中の計算過程はめちゃくちゃ」といった感じでしょうか。

After

学習の作り方とモデルの作り方が少し変わります。また、ラベルを部分和にしたので予測結果も部分和の数列になります。以下のコードでは予測結果の最後の要素(=数列全体の総和)だけを出力していますが、結果を全部表示して観察してみると面白いかもしれません。

なお、TimeDistributedラッパーは、時系列データのそれぞれに対してレイヤーを適用することを示しています。各時刻の内部状態に対して同じ変換 (Dense) を行い、その時点での出力を求めましょう、という意味になるわけですね。
レイヤーラッパー - Keras Documentation
Keras Recurrentレイヤーメモ:return_sequences, RepeatVector, TimeDistributed - Qiita

rnn_dynamic_after.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.layers.wrappers import TimeDistributed
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.cumsum(X, axis=1)
    # LSTMに与える入力は (サンプル, 時刻, 特徴量の次元) の3次元になる。
    # ラベルも時系列データになるので、同じ形状になる。
    return X.reshape((nb_of_samples, sequence_len, 1)), t.reshape((nb_of_samples, sequence_len, 1))

# 乱数シードを固定値で初期化
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(Masking(
    input_shape=(None, input_dim),
    mask_value=-1.0))
model.add(LSTM(
    num_hidden_units,
    return_sequences=True))
model.add(TimeDistributed(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次元の入力を与える。
# 学習データと同じ長さのデータ(総和は8.0)
test_10 = np.array([1, 1, 1, 1, 0, 1, 1, 1, 0, 1], dtype="float32").reshape((1, -1, 1))
print(model.predict(test_10)[:, -1, :]) # [[7.762647]]
# 学習データより短いデータ(総和は4.0)
test_05 = np.array([1, 1, 1, 1, 0], dtype="float32").reshape((1, -1, 1))
print(model.predict(test_05)[:, -1, :]) # [[4.1512775]]
# 学習データより長いデータ(総和は12.0)
test_15 = np.array([1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0], dtype="float32").reshape((1, -1, 1))
print(model.predict(test_15)[:, -1, :]) # [[9.912318]]

学習データより短い例では、Beforeと比べて誤差が小さくなっています。一方、長いデータに対してはうまく予測できていません。最後の予測は 12.0 に近い値を期待していますが、学習データの長さは10で、部分和が 12.0 になるような学習データが存在しませんので、無理もないでしょう。試しに総和が10未満になる長いデータを一つ試すと

# 学習データより長いデータ(総和は7.0)
test_15 = np.array([1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1], dtype="float32").reshape((1, -1, 1))
print(model.predict(test_15)[:, -1, :]) # [[6.985335]]

といったように、近い値を予測できました。
学習データとして十分長い列を使用すれば、この点は解決されそうです。
(いろいろ試すと、総和が10に近づくにつれて誤差が大きくなっているような気がします。学習データの作り方の性質上、値が10に近づくと総和がその値になるようなサンプルが少なくなっていくからでしょうか)

ちなみに、乱数シードを定数で初期化しているにもかかわらず、rnn_dynamic_after.py ではなぜか毎回結果が少しずつ変わってしまうようでした。rnn_dynamic_before.py だと大丈夫なのですが。

まとめ

数列の途中までを入力した時点での結果(部分問題の結果 or 計算過程)に意味があるような問題設定であれば、return_sequences=True を使ってみるとよいと思います。

everylittle
PythonやWebプログラミングなどのTipsをメモ代わりに投稿しています。たまに機械学習の話題もあります。
https://www.every-little.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした