LABOT 機械学習ブログ

本郷で機械学習の受託開発をしている会社のブログです。CEOの堀田がディープラーニング・データ分析周辺の技術情報を発信します。

【理論から実践まで】動かしながら学ぶ!ゼロからわかる再帰的ニューラルネットワーク(RNN)

この記事では再帰的ニューラルネットワーク (RNN) について解説をします。RNN の理論的な説明から入り、Keras を用いて実際に RNN を動かしてみます。単純RNN (SimpleRNN), LSTM, 双方向RNN (bidirectional RNN), deep RNN を用いてモデリングをします。なおこの記事はGoogle Colaboratory で動かすことができ、実行しながら読むことをおすすめします。

f:id:yoshihotta:20190628170824p:plain
ノートブックを開く

再帰的ニューラルネットワーク

再帰的ニューラルネットワーク(リカレントニューラルネットワーク、RNN))は系列データのモデルです。 各時刻 $t_1, t_2, \cdots,t_n$で$\vec{x_1}, \cdots, \vec{x_n}$が入力されたときベクトル$\vec{y_1}, \cdots, \vec{y_n}$ を予測するモデルです。 RNNは自然言語のモデルとして頻繁に使われます。 例えば「今日はいい天気で絶好の散歩日和です。」という文章があったとき「今日/は/いい/天気/で/絶好/の/散歩/日和/です/。 」と文を分割し(形態素解析といいます)、$\vec{x_1} = $今日, $\vec{x_2} =$ は, $\vec{x_3} =$ いい, $\vec{x_4} =$ 天気, $\vec{x_5} =$ で,$\vec{x_6} =$ 絶好,$\vec{x_7} =$ の,$\vec{x_8} =$ 散歩, $\vec{x_9} =$ 日和,$\vec {x _ {10}} =$です,$\vec{x _ {11}} =$ 。 とすると文は系列データになります。文の並びには意味があり、順番をバラバラにすると意味を捉えられなくなってしまうので、並びに意味があるデータ、つまり系列データとして扱わなければいけません。

RNNの利点としてマルコフ性を要求しないことがあります。 マルコフ性とは次の点が直前の$k$個の変数にのみ依存する性質のことです。 数式で書くと

P(x_n|x_1, \cdots, x_{n-1}) = P(x_n|x_{n-k}, \cdots, x_{n-1})

が満たされるということで、多くの系列データのモデルはマルコフ性を仮定します。 一方RNNはマルコフ性を仮定しないため、初めから直前のデータ全てを考慮して次の点を予測することができます。

再帰的ニューラルネットワークの一般的な表記

再帰的ニューラルネットワークのダイアグラムは以下です。

RNN-diagram

全ての時点を通じてRとOのパラメータが同じであることを強調するために$\theta$をわざと書いています。 $s_i$は状態と呼ばれ、ダイアグラムの横方向に伝搬していきます。 RNNは抽象的に以下のように書けます。


y_{1:n} = RNN(x_{1:n}, s_0) \\
y_i = O(s_i)\\
s_i = R(s_{i-1},x_i)

ここで$x_i, y_i, s_i$はベクトルで$x _ {1:n}$という記法は$x _ {1:n}=(x _ 1, \cdots, x _ n)$を表します。これからはベクトルと分かるときはベクトルの矢印を省略します

RNNは状態もRの引数にすることで過去の状態を考慮してモデリングすることができます。

RNNの使い方

RNNの典型的な使い方は系列データの特徴量抽出です。 RNNの最後の出力だけを使う方法と全ての時刻での出力を用いる方法があります。 それぞれ見ていきましょう。

RNNの最後の出力だけを用いる方法

この方法では各時点の情報が$s_i$を通じて右方向に伝搬し$y_n$に圧縮されます。

acceptor_diagram

図1: アクセプタ

時刻$1$から$n-1$の出力$y_i$は捨てます。 最後の出力$y_n$を予測値として直接用いることは通常しません。 $y_n$は予測を担う層の入力とすることが多いです。 例えば$y_n$を用いて2値分類するなら$\hat{t} = \mathrm{sigmoid}(\mathrm{Dense}(y_n))$とし、密な層とsigmoid関数をかけます。 このようなRNNの用途をアクセプタ (acceptor) と言います。

RNNの全ての時刻での出力を用いる方法

時刻$1$から$n$の出力$y_i$を全て用います。 各時刻で予測をします。 このようなRNNの利用法はtransducer (変換器)と呼ばれます。

transducer

図2: 変換器

典型的な応用に系列ラベリングがあります。 各時点の入力にクラスを割り当てるタスクです。 言語処理では言語モデルを作るのにtransducer としてのRNNが用いられます。 この例はRNNの言語処理での非常に重要な活用法ですので後で詳しく説明します。

単純RNN

まずは最も簡単なRNNから見ていきましょう。


s_i =R(x_i, s_{i-1}) = \tanh(s_{i-1}W^s + x_i W^x + b)\\
y_i = O(s_i) = s_i

と$R$と$O$を選びます。 つまり次の状態は前の状態と入力の和にし、時間方向は通常のニューラルネットワークと同様にします。 このRNNは単純RNN , Elman RNN, S-RNNと呼ばれます。 活性化関数$\tanh$があるため入力の順序を単純RNNは認識できます。 単純RNNの横方向は通常のニューラルネットワークと同じであるため、通常のニューラルネットワークと同様勾配消失問題が起こります。 つまり教師データが系列の最後に与えられると系列の初めの方には微弱なフィードバックしかかからなくなり、学習が進まなくなります。 単純RNNは長い系列をモデル化し、長距離の依存性を捉えることには向いていないのです。 勾配消失問題への対応策としてLSTM, GRUといったRNNが発明されました。

実践:客数を当てよう!単純RNN で回帰

RNNは系列データのモデリングに向いています。回帰では数字を当てることになります。株価、気温、客数といった数字を予測するのにRNNは向いています。航空会社の乗客数を予測してみましょう。まずはデータをここからダウンロードして、google drive にアップロードして下さい。

データをアップロードしたgoogle drive のディレクトリをdata_dirとします。私はMy Drive/Colab Notebooks/blog_data/passangers/にアップロードしたのでdata_dir = /content/gdrive/My Drive/Colab Notebooks/blog_data/passangers/となっています。これは人によって違うので書き換えて下さい。

from google.colab import drive
drive.mount('/content/gdrive')
Drive already mounted at /content/gdrive; to attempt to forcibly remount, call drive.mount("/content/gdrive", force_remount=True).
data_dir =  '/content/gdrive/My Drive/Colab Notebooks/blog_data/passangers/'

import pandas as pd
data = pd.read_csv(data_dir + 'international-airline-passengers.csv',sep=';', header=0, usecols=[1])
data.head()
Passengers
0 112
1 118
2 132
3 129
4 121

このデータはある航空会社の月ごとの客数で単位は千です。つまり最初のサンプルはある月の乗客数が $112,000$ 人いたことを表します。データをプロットしてみましょう。

import pandas as pd
import matplotlib.pyplot as plt
plt.plot(data)
[<matplotlib.lines.Line2D at 0x7f25c26c6978>]

png

データは右肩上がりで周期性を持っています。次のデータ点を当てるためにはこの2つの特徴を捉えられるくらい昔までデータを見れば大丈夫そうです。ここでは単純RNNを使います。

前処理をします。今回は目的変数のスケールを調整して[0, 1]の間に収まるようにします。scikit-learnMinMaxScalerを使えばできます。

from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler(feature_range=(0, 1))
data = scaler.fit_transform(data)
plt.plot(data)
[<matplotlib.lines.Line2D at 0x7f25c26a2cf8>]

png

データセットを訓練データとテストデータに7:3に分けます。時系列データなのである点の予測をするのに未来の点を用いてはならないため、ランダムにsplitしてはいけないことに注意して下さい。

n_train = int(len(data) * 0.7)
n_test = len(data) - n_train
train, test = data[0:n_train,:], data[n_train:len(data),:]

直前の10個の点を見て次の点を予測することにします。

import numpy as np

def create_dataset(data, input_size):
    x_data, y_data = [], []
    for i in range(len(data) - input_size-1):
        input_data = data[i:(i+input_size), 0]
        x_data.append(input_data)
        y_data.append(data[i + input_size, 0])
    return np.array(x_data), np.array(y_data)

input_size = 10
x_train, y_train = create_dataset(train, input_size)
x_test, y_test = create_dataset(test, input_size)

x_train = np.reshape(x_train, (x_train.shape[0], x_train.shape[1], 1))
x_test = np.reshape(x_test, (x_test.shape[0], x_test.shape[1], 1))

Keras で 単純RNN を使うのは簡単です。以下のコードだけでモデルができます。

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Activation, SimpleRNN

model = Sequential()
model.add(SimpleRNN(10, input_shape=(input_size, 1))) 
model.add(Dense(1))
model.compile(loss='mean_squared_error', optimizer='adam')
model.fit(x_train, y_train, epochs=100, batch_size=1, verbose=2)

モデルを可視化してみましょう。

from IPython.display import SVG
from tensorflow.python.keras.utils.vis_utils import model_to_dot
SVG(model_to_dot(model).create(prog='dot', format='svg'))

svg

この図は分かりにくいですが、図1と同じです。

プロットして予測値と実際の値を見比べましょう。

test_pred = model.predict(x_test)
test_pred = scaler.inverse_transform(test_pred)

test_pred_slug = np.empty_like(data)
test_pred_slug[:, :] = np.nan
test_pred_slug[len(y_train)+(input_size*2)+1:len(data)-1, :] = test_pred

plt.plot(scaler.inverse_transform(data), label='Actual data')
plt.plot(test_pred_slug, label='Prediction')
plt.legend()
plt.show()

png

biRNN

RNNは頭から順番に読んで出力をしました。 例えば"Sometimes people say neural networks model brain"という文中でのmodelの品詞を当てるタスクを考えます。 この文は「ニューラルネットワークは脳のモデルであると言われることがある」という意味で、modelは動詞です。 modelの前後に名詞があることがmodelが名詞であることの手がかりになっています。 もしmodelの後ろの単語 brain を知らなかったらmodelの品詞を当てるのは難しいのではないでしょうか。 系列にラベルを付けていくタスクにおいて頭からだけでなく、後ろから読んでいくことも有用であることを示唆しています。

頭とお尻の両方から系列を読むモデルを双方向再帰的ニューラルネットワーク (bidirectional RNN, biRNN)と呼びます。 biRNNは系列内の任意の過去と未来を見ることができるモデルなのです。

biRNN

図3: biRNN

系列$x _ {1:n}=(x _ 1, \cdots, x _ n)$が与えられたとき時刻$j$での出力を考えましょう。 biRNNでは前向き状態 (forward state) $s _ {i}^f$を$x _ {1:j}$基づいて計算し、後向き状態 (backward state) $s _ {i}^b$を$x _ {j:n}$に基づいて計算します。 そして$s _ {i}^f$と$s _ {i}^b$を合わせて時刻$j$での状態として利用します。 抽象的な書き方をすると

$$y _ i = (O ^ f(s _ {i} ^ f), O ^ b(s _ {i} ^ b))$$ $$s _ {i} ^ f = R ^ f(s _ {i} ^ f, x _ i)$$ $$s _ {i} ^ b = R ^ b(s _ {i} ^ b, x _ i)$$

となります。

Deep RNN

RNNは積み上げて性能を向上させることができます。 これを多層RNN (deep RNN) と言います。

deep-RNN

図4: Deep RNN

下の層の出力を次の層の入力とします。 横の一行が一つのRNNです。 行はRNNでなくbiRNNとしてもいいです。 最後の行の出力がこのモデルの出力になります。 前述と同様にdeep RNNをアクセプタとして用いるために$y _ {n}$だけを利用してもいいですし、transucer として用いるために$y _ {1:n}$全てを利用してもいいです。 なぜ性能が上がるのかは理論的にはよく分かっていない上に一層のRNN に比べてdeep RNNは計算コストがかかりますが、経験則としてdeep RNNは一層のRNNより性能がよくなることがあるので性能を追求したい場合は試してみるとよいでしょう。

実践:スパムをはじけ!RNNで分類

RNNの分類問題の例としてスパムフィルターの実装を説明します。まずはここからデータをダウンロードしてgoogle drive にアップロードしましょう。アップロードするのはSMSSpamCollectionです。

$wget https://archive.ics.uci.edu/ml/machine-learning-databases/00228/smsspamcollection.zip
$unzip msspamcollection.zip
$ls msspamcollection.zip
>SMSSpamCollection readme

このデータセットはSMSのデータセットです。SMSSpamCollectionを見てみましょう。

from google.colab import drive
drive.mount('/content/gdrive')
Drive already mounted at /content/gdrive; to attempt to forcibly remount, call drive.mount("/content/gdrive", force_remount=True).
home = '/content/gdrive/My Drive/Colab Notebooks/'
data_dir = home + 'blog_data/spam/'
import pandas as pd
data = pd.read_csv(data_dir + 'SMSSpamCollection',sep='\t', header=-1, names=['type', 'text']) 
data.head()
type text
0 ham Go until jurong point, crazy.. Available only ...
1 ham Ok lar... Joking wif u oni...
2 spam Free entry in 2 a wkly comp to win FA Cup fina...
3 ham U dun say so early hor... U c already then say...
4 ham Nah I don't think he goes to usf, he lives aro...
len(data)
5572

このデータセットは5572件のSMSのメッセージを含んでいます。一列目がメッセージがスパム (spam)か普通のメッセージ (ham) を表し、二列目がメッセージです。 一列目の変数 (ham/spam)をダミー変数 (0/1)に変換します。

import numpy as np
data['class'] = np.where(data['type']=='ham', 0, 1)
data.head()
type text class
0 ham Go until jurong point, crazy.. Available only ... 0
1 ham Ok lar... Joking wif u oni... 0
2 spam Free entry in 2 a wkly comp to win FA Cup fina... 1
3 ham U dun say so early hor... U c already then say... 0
4 ham Nah I don't think he goes to usf, he lives aro... 0

データを訓練データとテストデータに7:3で分けます。

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(data['text'], data['class'], test_size=0.30, random_state=42)

前処理の流れは以下のようになります。 1. トークン化。文をスペースで区切り、単語に分けます。単語は数字に置換されます。例えばI have a pen で I -> 1, have -> 4, a -> 10, pen -> 2 と置換することにすると [1, 4, 10, 2] となります。 2. パディング。Keras で LSTM に系列データを流すには全てのサンプルが同じ長さである必要があります。最大長を決めて、サンプルの長さが最大長に満たなければ0で埋めます。これをパディングと言います。

from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences

max_len = 100  

tokenizer = Tokenizer()
tokenizer.fit_on_texts(data['text'])
x_train = tokenizer.texts_to_sequences(X_train)
x_test = tokenizer.texts_to_sequences(X_test)

print('トークン化した後:')
print(x_train[0])

x_train = pad_sequences(x_train, maxlen=max_len)
x_test = pad_sequences(x_test, maxlen=max_len)

print('パディングした後:')
print(x_train[0])
トークン化した後:
[372, 233, 336, 557, 963, 434, 1, 1342, 184, 2105]
パディングした後:
[   0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0  372  233  336  557  963  434    1 1342
  184 2105]

ここではbidirectional LSTMを使いましょう。LSTMの説明はしていませんが、LSTMがRNNの一種であることだけ覚えておけば十分です。KerasではSimpleRNNを使うのもLSTMのような複雑なモデルを使うのも同じくらい簡単です。実際下のコードでLSTMをSimpleRNNに変えても動きます。bidirectional LSTM は RNN の一種である LSTM 2つを図3のように双方向に繋げたものです。Keras で bidirectional LSTM の層を作るには model.add(Bidirectional(LSTM(16, return_sequences=False))) と書くだけです。

※以下のコードの実行には時間がかかります。Google Colaboratory を使っている方は「ランタイム>ランタイムのタイプを変更」から GPU を使うようにすることをお勧めします。

from keras.models import Sequential
from keras.layers import Bidirectional, Dense, Embedding, LSTM

vocabulary_size = len(tokenizer.word_index) + 1  

model = Sequential()
model.add(Embedding(input_dim=vocabulary_size, output_dim=32))
model.add(Bidirectional(LSTM(16, return_sequences=False)))
model.add(Dense(1, activation='sigmoid'))
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
model.summary()

# 学習
history = model.fit(
    x_train, y_train, batch_size=32, epochs=10,
    validation_data=(x_test, y_test)
)

pred = model.predict_classes(x_test)
print(confusion_matrix(y_test, pred))
[[1446    2]
 [  10  214]]
len(tokenizer.word_index)
9009
import matplotlib.pyplot as plt
plt.plot(history.history['acc'])
[<matplotlib.lines.Line2D at 0x7f25c0b79898>]

png

from IPython.display import SVG
from tensorflow.python.keras.utils.vis_utils import model_to_dot
SVG(model_to_dot(model).create(prog='dot', format='svg'))

svg

正しく分類できた文と間違って分類した文の数を見てみましょう。これはconfusion matrix を使うと分かります。confution matrix の要素を$c _ {ij}$とします。$c _ {ij}$は予測クラスが$j$で正解クラスが$i$であったテストデータのサンプル数です。

from sklearn.metrics import confusion_matrix
pred = model.predict_classes(x_test)
print(confusion_matrix(y_test, pred))
[[1446    2]
 [  10  214]]

実際にはスパムなのに普通のメッセージ (ham)だと判定したメッセージが11個あります。このような予測を偽陰性といいます。偽陰性のサンプルを見てみましょう。

false_negative = X_test[y_test > pred.reshape(-1)]
for fn in false_negative:
  print(fn)
Babe: U want me dont u baby! Im nasty and have a thing 4 filthyguys. Fancy a rude time with a sexy bitch. How about we go slo n hard! Txt XXX SLO(4msgs)
Hello darling how are you today? I would love to have a chat, why dont you tell me what you look like and what you are in to sexy?
Do you realize that in about 40 years, we'll have thousands of old ladies running around with tattoos?
Sorry I missed your call let's talk when you have the time. I'm on 07090201529
PRIVATE! Your 2003 Account Statement for 078
Email AlertFrom: Jeri StewartSize: 2KBSubject: Low-cost prescripiton drvgsTo listen to email call 123
100 dating service cal;l 09064012103 box334sk38ch
Did you hear about the new "Divorce Barbie"? It comes with all of Ken's stuff!
The current leading bid is 151. To pause this auction send OUT. Customer Care: 08718726270
You'll not rcv any more msgs from the chat svc. For FREE Hardcore services text GO to: 69988 If u get nothing u must Age Verify with yr network & try again

人間でも一見スパムだと分からない文が多いですね。LSTMも間違えてしまいました。

Deep RNN も使ってみましょう。ここでは RNN として LSTM を使います。図4 のような4層のアーキテクチャを作ってみます。最初の3層は各時点での出力を次の層の入力とします。最後の1層だけ最後の時点の出力だけをDense層に渡し、それ以外の出力は使いません。return_sequences = Trueとすると各時点での出力が次の層に渡り、Falseとすると最後の出力だけが使われます。

model = Sequential()
model.add(Embedding(input_dim=vocabulary_size, output_dim=32))
model.add(LSTM(16, return_sequences=True))
model.add(LSTM(16, return_sequences=True))
model.add(LSTM(16, return_sequences=True))
model.add(LSTM(16, return_sequences=False))
model.add(Dense(1, activation='sigmoid'))
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
model.summary()

# 学習
history = model.fit(
    x_train, y_train, batch_size=32, epochs=10,
    validation_data=(x_test, y_test)
)

pred = model.predict_classes(x_test)
print(confusion_matrix(y_test, pred))
[[1441    7]
 [  13  211]]
SVG(model_to_dot(model).create(prog='dot', format='svg'))

svg

最新のモデルに行く前に

これまでRNNの一般的な表記について解説しました。 RNNは一つのモデルを指すわけではなく、この記事中のダイアグラムで書けるモデル全般を指します。 単純RNN, LSTM, GRUは全てRNNの一種です。 LSTMやGRUは込み入った工夫がなされているので複雑ですが、混乱したらこれらはRNNの一種であることを思い出すと迷わずに済むでしょう。LSTMとGRUの解説は今後執筆する予定です。乞うご期待!