読者です 読者をやめる 読者になる 読者になる

kivantium活動日記

プログラムを使っていろいろやります

Chainerで学ぶLSTM

このブログで何回も取り上げているように、ニューラルネットワークを用いた機械学習はかなりの力を発揮します。畳み込みニューラルネットワーク(convolutional neural network, CNN)は画像中で近くにあるピクセル同士の関係に注目するなど画像の特徴をうまくとらえたネットワークを構築することでかなりの成功を収めています。ノーフリーランチ定理が示唆するように万能の機械学習器は存在しないため、対象とするデータの特徴を捉えた学習器を構築することが機械学習の精度を上げる上で重要になります。

そこで今回は時系列データの解析に向いた回帰結合ニューラルネットワーク(recurrent neural network, RNN)の精度を上げるのに重要なLSTMを取り上げます。

この記事では誤差逆伝搬などのニューラルネットワークの基本知識は説明しません。誤差逆伝搬についてはPRMLの5章やNeural networks and deep learningのHow the backpropagation algorithm worksがよい解説だと思います。

回帰結合ニューラルネットワークとback-propagation through time法

通常のニューラルネットワークではそれぞれの入力に対して別々の予測を行うため、入力が時系列データで時間ごとの相関を持っていたとしてもその特徴を活かすことができません。その問題を解消するために考案されたのが回帰結合ニューラルネットワーク(RNN)です。ここでは代表的なエルマンネットワーク(Elman, 1988)を取り上げます。

エルマンネットワークでは入力データとして時刻tにおけるデータの他に時刻t-1における隠れ層のデータを利用します。(図は論文から引用)
f:id:kivantium:20160131155953p:plain:w400
このようなネットワーク構成にすることで過去の入力が現在の予測に影響を与えられるようになり、時間ごとの関係性を表現することができるようになります。

最初の論文では過去の隠れ層からの結合重みは固定されていて学習の対象ではなかったようですが、1988年のWerbosの論文(手に入らなかったので内容は確認していません)などで提案されたback-propagation through time(BPTT)という方法を使って、過去の隠れ層からの入力の重みも学習できるようになりました。

BPTTではRNNを隠れ層の入力を展開した大きなニューラルネットワークとみなして誤差逆伝搬法を適用することで学習を行います。
f:id:kivantium:20160131170234p:plain:w600

右側の展開図を見れば分かるように、BPTTで誤差を伝搬させるには過去の全ての活性化状態を記録する必要があるので必要な計算量やメモリが多くなります。また、誤差逆伝搬を行う時には伝搬されてきた誤差に活性化関数の微分を掛けることになりますが、BPTTでは層がたくさんあるので掛けられる回数がとても多くなります。1より大きい値をたくさん掛けた場合は伝搬されてくる誤差が爆発して一度の更新が大きくなるので学習が安定しませんし、1より小さい値を掛けていった場合は1回の変更が小さくなりすぎて過去の入力の重みがほとんど更新されなくなってしまいます。

勾配の爆発・消失に対応する一番簡単な方法としては活性化関数を1に固定することが考えられますが、これだと表現能力が低すぎて高度なことができそうにありません。過去の記憶を保持できる高い表現能力を持ったモデルを考える必要があります。そこで提案されたのがLSTMです。

Long Short-Term Memory

LSTMはLong Short-Term Memoryの略で(Hochreiter, 1997)で提案されました。ここで提案された初期モデルを改良した様々な変種が現在ではよく使われています。ここではChainerのサンプルで実装されている(Wojciech, 2015)の説明に沿ってLSTMの中身を見ていきます。

f:id:kivantium:20160131172218p:plain:w500

LSTMは上の図のような構造になっています。構成要素は

  • メモリセル
  • 入力 (input gate)
  • 入力判断ゲート (input modulation gate)
  • 忘却判断ゲート (forget gate)
  • 出力判断ゲート (output gate)
  • 出力

の6つです。(訳語は人工知能学会の「深層学習」に揃えたので直訳ではありません)

LSTMは記憶を保持できる隠れ層として使われます。ここではl番目の隠れ層をLSTMにする場合を考えます。

メモリセルは過去の状態の記憶を担当し、c_tで表します。(c_tn次元ベクトルです)

入力は時刻tにおけるl-1番目の隠れ層の出力h_t^{l-1}と時刻t-1におけるl番目の隠れ層の出力h_{t-1}^lからなります。同じものが以下の3つの判断ゲートの入力にもなります。

入力判断ゲートはメモリセルに加算される値を調整する役割を持ちます。このゲートの存在によって直近のあまり関係ない情報が影響してメモリセルが持つ重要な情報が消失してしまうのを防ぐことができます。

忘却判断ゲートはメモリセルの値が次の時刻でどれくらい保持されるかを調整する役割を持ちます。このゲートはLSTMが最初に提案された1997年の論文では存在しませんでした。1999年のGersの論文で提案され、状況が変わって過去の情報があまり役に立たなくなったときに過去の情報を潔く捨てることができるようにすることが目的となっています。ちなみにChainerのLSTMはこの論文をもとに実装されています。

出力判断ゲートはメモリセルの値が次の層にどれだけ影響するかを調整する役割を持ちます。このゲートの存在によってネットワーク全体が短期記憶に影響されて長期記憶が邪魔されるのを防ぐことができるようです。

出力はh_t^{l}で、n次元ベクトルです。


LSTMの表式は次のようになっています。
f:id:kivantium:20160131175400p:plain:w400
⦿は要素ごとの積をあらわし、sigmやtanhは要素ごとに適用されます。T_{2n, 4n}は2n次元ベクトル(h_t^{l-1}h_{t-1}^lを並べたもの)から4n次元ベクトル(入力と3種類の判断ゲートそれぞれにつきn次元で合計4n次元)ベクトルをつくる写像で、通常のニューラルネットワークで重みを掛ける演算に相当します。それぞれの文字がベクトルであることを意識しないとかなり混乱します。図を再掲するので見比べながら理解してください。
f:id:kivantium:20160131172218p:plain:w500

それぞれのゲートは行列の積と活性化関数だけで構成されているのでLSTMも誤差逆伝搬で学習させることができます(式を書くと大変なので論文を参照してください)

Chainerでの実装

ここでは最初にLSTMを提案した論文での実験に使われた、入力1層・隠れ層(LSTM)1層・出力1層のニューラルネットワークに近いものをChainerで書いてその実装の理解を試みます。今回使ったバージョンは1.6です。ChainerのLSTMの実装は初期バージョンから結構変わったらしいので他のサイトにある古いバージョンを元に書かれた記事を見ると混乱するので注意してください。
僕が書いたネットワーククラスは次の通りです。

class LSTM(chainer.Chain):
    def __init__(self, in_size, n_units, train=True):
        super(LSTM, self).__init__(
            embed=L.EmbedID(in_size, n_units),
            l1=L.LSTM(n_units, n_units),
            l2=L.Linear(n_units, in_size),
        )

    def __call__(self, x):
        h0 = self.embed(x)
        h1 = self.l1(h0)
        y = self.l2(h1)
        return y

    def reset_state(self):
        self.l1.reset_state()

Chainerではパラメータを保持する部分の宣言と順伝搬の定義が分かれています。順伝搬さえ正しく書ければ逆伝搬はライブラリが面倒を見てくれます。
パラメータ宣言の部分を詳しく見ます。

super(LSTM, self).__init__(
    embed=L.EmbedID(in_size, n_units),
    l1=L.LSTM(n_units, n_units),
    l2=L.Linear(n_units, in_size),
)
  • 2行目のEmbedIDは数字で与えたラベルをin_size次元の1-of-k法で表現したあと重みを掛けてn_units次元ベクトルとして出力します。(ドキュメント
  • 3行目のLSTMは見ての通りLSTMです。これはドキュメントソースコードを上の式と比較しながら眺めると何をやっているのか分かります。
  • 4行目のLinearはn_units次元の入力に重みを掛けてin_size次元のベクトルを出力します。

順伝搬部分は演算を順に適用しているだけです。reset_state()は現在の出力とメモリセルの内容を消去します。

実験

上で書いたLSTMのモデルを使って「LSTMがどれくらいの期間情報を保持できるか」について調べる実験を行います。情報保持期間を定義するのは難しいのですが、LSTMが最初に提案されたときの論文のExperiment2のTask 2aを試してみようと思います。

このタスクは a_1, a_2, \cdots,a_{p-1} x, yというp+1種類のデータを用意して、
 (y, a_1, a_2, \cdots, a_{p-1}, y)
 (x, a_1, a_2, \cdots, a_{p-1}, x)
という2つの文字列を学習させた後、文字列の最初から順に1文字ずつ与えて次の文字を予測させるものです。2つの文字列は最初と最後の文字以外は全て同じなので、最後の1文字を当てるためには最初の1文字を最後まで記憶しておく必要があります。pが大きくなるほど保持しなければならない期間が長くなるので難しいタスクになります。

訓練は1文字ずつ行います。隠れ層のユニット数は論文に合わせて4つにしました。学習率は論文では1.0になっていましたがうまく学習できなかったのでAdamを使って学習率を自動で調整するようにしてあります。以下ソースコードです。

#! /usr/bin/env python
# -*- coding: utf-8 -*- 
from __future__ import print_function
import argparse
import math
import sys
import time

import numpy as np
from numpy.random import *
import six

import chainer
from chainer import optimizers
from chainer import serializers
import chainer.functions as F
import chainer.links as L
# cudaがある環境ではコメントアウト解除
#from chainer import cuda

# LSTMのネットワーク定義
class LSTM(chainer.Chain):
    def __init__(self, p, n_units, train=True):
        super(LSTM, self).__init__(
            embed=L.EmbedID(p+1, n_units),
            l1=L.LSTM(n_units, n_units),
            l2=L.Linear(n_units, p+1),
        )

    def __call__(self, x):
        h0 = self.embed(x)
        h1 = self.l1(h0)
        y = self.l2(h1)
        return y

    def reset_state(self):
        self.l1.reset_state()

# 引数の処理
parser = argparse.ArgumentParser()
parser.add_argument('--gpu', '-g', default=-1, type=int,
                    help='GPU ID (negative value indicates CPU)')
args = parser.parse_args()

# パラメータ設定
p = 5          # 文字列長
n_units = 4    # 隠れ層のユニット数

# 訓練データの準備
# train_data[0]がy、train_data[1]がxの方
# a_1を0にしたので添字がひとつずれている
train_data = np.ndarray((2, p+1), dtype=np.int32)
train_data[0][0] = train_data[0][p] = p
train_data[1][0] = train_data[1][p] = p-1
for i in range(p-1):
    train_data[0][i+1] = i
    train_data[1][i+1] = i

# 訓練データの表示
print(train_data[0])
print(train_data[1])


# モデルの準備
lstm = LSTM(p , n_units)
# このようにすることで分類タスクを簡単にかける
# 詳しくはドキュメントを読むとよい
model = L.Classifier(lstm)
model.compute_accuracy = False
for param in model.params():
    data = param.data
    data[:] = np.random.uniform(-0.2, 0.2, data.shape)

# cuda環境では以下のようにすればよい
#xp = cuda.cupy if args.gpu >= 0 else np
#if args.gpu >= 0:
#    cuda.get_device(args.gpu).use()
#    model.to_gpu()

# optimizerの設定
optimizer = optimizers.Adam()
optimizer.setup(model)

# 訓練を行うループ
display = 1000  # 何回ごとに表示するか
total_loss = 0  # 誤差関数の値を入れる変数
for seq in range(100000):
    sequence = train_data[randint(2)] # ランダムにどちらかの文字列を選ぶ
    lstm.reset_state()  # 前の系列の影響がなくなるようにリセット
    for i in six.moves.range(p):
        x = chainer.Variable(xp.asarray([sequence[i]]))   # i文字目を入力に
        t = chainer.Variable(xp.asarray([sequence[i+1]])) # i+1文字目を正解に
        loss = model(x, t)  # lossの計算

        # 出力する時はlossを記憶
        if seq%display==0:
            total_loss += loss.data

        # 最適化の実行
        model.zerograds()
        loss.backward()
        optimizer.update()

    # lossの表示
    if seq%display==0:
        print("sequence:{}, loss:{}".format(seq, total_loss))
        total_loss = 0
    # 10回に1回系列ごとの予測結果と最後の文字の確率分布を表示
    if seq%(display*10)==0:
        for select in six.moves.range(2):
            sequence = train_data[select]
            lstm.reset_state()
            print("prediction: {},".format(sequence[0]), end="")
            for i in six.moves.range(p):
                x = chainer.Variable(xp.asarray([sequence[i]]))
                data = lstm(x).data
                print("{},".format(np.argmax(data)), end="")
            print()
            print("probability: {}".format(data))

p=5の場合は10000回で正しい結果を学習しました。以下ログです。

[5 0 1 2 3 5]
[4 0 1 2 3 4]
sequence:0, loss:8.83233463764
prediction: 5,0,0,0,0,0,
probability: [[ 0.16483526  0.0933727   0.16314429 -0.00496219 -0.0979648  -0.02232582]]
prediction: 4,0,0,0,0,0,
probability: [[ 0.16462579  0.09304499  0.16339977 -0.00501352 -0.09793703 -0.0221054 ]]
sequence:1000, loss:2.02257156372
sequence:2000, loss:0.989713907242
sequence:3000, loss:0.746638059616
sequence:4000, loss:0.714985132217
sequence:5000, loss:0.628080129623
sequence:6000, loss:0.641963720322
sequence:7000, loss:0.0387046337128
sequence:8000, loss:0.00255155563354
sequence:9000, loss:0.000325202941895
sequence:10000, loss:3.95774841309e-05
prediction: 5,0,1,2,3,5,
probability: [[-23.23191071 -10.50747681  -9.53220177  -7.95053101  -6.23664427
    4.33853149]]
prediction: 4,0,1,2,3,4,
probability: [[ -5.44653797  -7.92300463 -22.23236465  -6.1331768    7.45352268
   -3.02756929]]

pを5ずつ変えて実験してみたところp=15まではちゃんと学習できましたが、p=20では100万回学習させても正しく学習させることができませんでした。もっと回数を増やせば学習できるかもしれませんが学習時間が長くなりすぎるので厳しそうです。

元論文の実験では5040回の学習でp=100のときにも学習できると書かれています。元論文にはない忘却ユニットの存在が学習回数を増やしてしまっている、Chainerが元論文と違う学習をさせている、パラメータが若干違うなどの可能性はありますが今のところ原因はよく分かっていません。



時系列データの代表例といえば株価や為替の相場です。絶対に働きたくない - kivantium活動日記で失敗した株価予測を今度こそ成功させるためにもう少しいろいろなモデルについて勉強したいと思っています。

参考文献

誤差逆伝搬などのニューラルネットワークの基本については

パターン認識と機械学習 上

パターン認識と機械学習 上

の5章がおすすめです。

研究の流れや日本語訳については

深層学習: Deep Learning

深層学習: Deep Learning

を参考にしました。

全体を通してLONG SHORT-TERM MEMORY (Hochreiter, 1997)Recurrent Neural Network Regularization (Zaremba, 2014)の記述を参考にしました。

LSTMのChainerでの実装はChainerのexamples/ptbを参考にしました。

LSTMを解説した分かりやすい記事としてはUnderstanding LSTM Networks -- colah's blogがおすすめです。