#はじめに
この記事では,公式のCaffeを用いたLSTMサンプルプログラムの解説を行います.Caffeを用いたLSTMサンプルプログラムの解説というと,有名なものでChristopherさんのブログがありますが,これは公式のCaffeではなく,Junhyuk Ohさんが独自にLSTMを実装した改造Caffe(以降Caffe-LSTMと記述)を使用したものです.このCaffe-LSTMを使用しても良いのですが,公式CaffeにJeff Donahueさんが実装したLSTMがマージされて以降,Caffe-LSTMはメンテナンスしないとJunhyukさんが明言しているので,将来的に公式CaffeのLSTMを使用したほうが良いのは明らかです.しかし,驚くべきことに公式Caffeを用いたLSTMのサンプルプログラムはネット上に無いといっても過言ではありません!(あったとしても肝心な情報が抜けてる).そのため,これから公式CaffeでLSTMに取り組もうとしている方を幸せにするべく,この記事は書かれました.
#想定読者
- 基本的なLSTMの構造・仕組みを理解している方.
- CaffeでLSTMしないといけない方.
#実行環境
- Ubuntu 16.04 LTS
- Python 2.7
#実装
- 基本的にはChristopherさんのブログの流れに沿って説明していきます.
- Caffe-LSTMと,公式CaffeにおけるLSTMの実装の違いも逐次説明します.
- 記載するclock.prototxtとclockSolver.prototxtとclockLSTM.pyをcaffe直下におけば,clockLSTM.pyは動くはずです.
##問題設定
入力がすべて0で,指定された波形を再現するように学習するという少し変わった問題設定(ClockworkRNN).
##Network
以下に示すclock.prototxtで,15個のcellを持った,1層のLSTM層を定義しています.cellの数はLSTMのメモリのキャパシティを示しています.
name: "LSTM"
input: "data"
input_shape { dim: 320 dim: 1 dim: 1}
input: "clip"
input_shape { dim: 320 dim: 1 }
input: "label"
input_shape { dim: 320 dim: 1 }
layer {
name: "Silence"
type: "Silence"
bottom: "label"
include: { phase: TEST }
}
layer {
name: "lstm1"
type: "LSTM"
bottom: "data"
bottom: "clip"
top: "lstm1"
recurrent_param {
num_output: 15
weight_filler {
type: "gaussian"
std: 0.1
}
bias_filler {
type: "constant"
}
}
}
layer {
name: "ip1"
type: "InnerProduct"
bottom: "lstm1"
top: "ip1"
inner_product_param {
num_output: 1
weight_filler {
type: "gaussian"
std: 0.1
}
bias_filler {
type: "constant"
}
}
}
layer {
name: "loss"
type: "EuclideanLoss"
bottom: "ip1"
bottom: "label"
top: "loss"
include: { phase: TRAIN }
}
###<データ入力部分>
####"data"のinput_shape
Caffe-LSTM
Caffe-LSTMを使用するなら,長さが「320」のシーケンスを「1」つ,各データサイズが1の場合,"data"のinput_shapeを次のように指定してやれば良かったのでした.
name: "LSTM"
input: "data"
input_shape { dim: 320 dim: 1 }
input: "clip"
input_shape { dim: 320 dim: 1 }
input: "label"
input_shape { dim: 320 dim: 1 }
公式Caffe
しかし,公式Caffeを使用するなら,長さが「320」のシーケンスを「1」つ,各データサイズが「1」の場合,"data"のinput_shapeを次のように指定しなければいけません.
name: "LSTM"
input: "data"
input_shape { dim: 320 dim: 1 dim: 1}
input: "clip"
input_shape { dim: 320 dim: 1 }
input: "label"
input_shape { dim: 320 dim: 1 }
これは,公式CaffeのLSTMレイヤーが"data"として,
「T×N×... (T :シーケンスの長さ,N :シーケンスの数,... :各データサイズ)という形のデータを受け取る」
という風に決まっているからです.
したがって,データサイズが1なら実質320×1と変わらないのですが,プログラム的な問題で,320×1×1と指定する必要があります.
####"clip"とは
"clip"として適切な配列を渡すことで,文字通りシーケンスをclipすることができます.
"clip"で指定される入力は,0か1の値を取る配列です.0はシーケンスの先頭を示し,1はシーケンスが連続していることを示します.
例えば,長さが「10」のシーケンスを「1」つ,各データサイズが「1」となるシーケンスデータを受け取るようなネットワークに,次に示す二つの異なるシーケンスを続けて入力したいとします.
- 長さが「3」のシーケンスを「1」つ,各データサイズが「1」
- 長さが「7」のシーケンスを「1」つ,各データサイズが「1」
こうした場合,ネットワークの大きさ(シーケンスの長さが「10」)は固定なので,二つのシーケンスは別々の異なるシーケンスだとネットワークに教える必要があります.それを実現するのが"clip"であり,上の二つのシーケンスが入力になる場合,clipを
[0, 1, 1, 0, 1, 1, 1, 1, 1, 1]
という10×1の配列にすれば,ネットワークに二つのシーケンスは異なるシーケンスだと教えることができます.具体的には,clipの各値がcell stateとhidden stateに掛け算されます(0が来たらcell stateとhidden stateは0にリフレッシュされる).
では,長さが10を超える,次のシーケンスの場合はどうなるのでしょうか
- 長さが「14」のシーケンスを「1」つ,各データサイズが「1」
この場合は,1回目のforward passで
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1]
という配列を"clip"として渡してやり,次のforward passで
[1, 1, 1, 1, 0, *, *, *, *, *] // *は0または1
という配列を"clip"として渡してやれば,長さが「14」のシーケンスだとネットワークに教えることができます.(この場合,学習時には長さが10を超えるシーケンスはtruncateされることに注意してください.詳しい説明はTruncated BPTTで調べてみてください.)
このように,"clip"で文字通りシーケンスをclipして,固定されたネットワークアーキテクチャで任意の長さのシーケンスを扱うことができます.
###<LSTMレイヤー部分>
公式CaffeのLSTMレイヤーは,先ほど説明した二つの入力を受け取ります."data"と"clip"です.
- "data": T×N×... (T :シーケンスの長さ,N :シーケンスの数,... :各データサイズ)
- "clip": T×N (T :シーケンスの長さ,N :シーケンスの数)
例えば動画を入力データにしたい場合は,T×N×...はT×N×C×H×Wとなります.(C: Channel, H: Height, W: Width)
LSTMレイヤーではcellの数が指定でき,recurrent_paramのnum_outputで指定することができます.
公式Caffe
layer {
name: "lstm1"
type: "LSTM"
bottom: "data"
bottom: "clip"
top: "lstm1"
recurrent_param {
num_output: 15
weight_filler {
type: "gaussian"
std: 0.1
}
bias_filler {
type: "constant"
}
}
}
Caffe-LSTM
cellの数はCaffe-LSTMでも同じくLSTMレイヤーで指定できますが,Caffe-LSTMでは加えてclipping_thresholdというパラメータも指定できます.
layer {
name: "lstm1"
type: "Lstm"
bottom: "data"
bottom: "clip"
top: "lstm1"
lstm_param {
num_output: 15
clipping_threshold: 0.1
weight_filler {
type: "gaussian"
std: 0.1
}
bias_filler {
type: "constant"
}
}
}
clipping_thresholdはGradient Clippingをするために必要なパラメータで,公式Caffeではネットワークファイルではなく,ソルバーファイルで,clip_gradientsとして指定します.Gradient Clippingとは,勾配爆発問題への対応策の1つであり,次のSolver節でより詳しく説明します.
##Solver
以下に示すsolverファイルで必ず欠かしてはいけないのは,先に述べたclip_gradientsです.
net: "clock.prototxt"
test_iter: 1
test_interval: 1000000
base_lr: 0.0001
momentum: 0.95
lr_policy: "fixed"
display: 200
max_iter: 100000
solver_mode: CPU
average_loss: 200
clip_gradients: 10
clip_gradientsで行っているのは,Gradient Clippingの際の閾値の設定です.Gradient Clippingとは,勾配爆発を防ぐための簡単な仕組みで,逆伝播してきた勾配が爆発しないように,その勾配がある閾値を超えたらその閾値でclippingすることを言います.この閾値を指定しているのが,ソルバーファイルにおけるclip_gradientsです.
DeepLearningの分野で有名な勾配消失問題と勾配爆発問題はRNNで初めて指摘された問題ですが,RNNにおいて勾配消失問題はLSTMという新しいアーキテクチャの提案によって,勾配爆発問題はGradient Clippingという簡単な仕組みによって,それぞれ改善されました.
##Python
import sys
sys.path.insert(0, 'python')
import caffe
caffe.set_mode_cpu()
solver = caffe.SGDSolver('clockSolver.prototxt')
import numpy as np
# make complex wave
a = np.arange(0,3.2,0.01)
d = 0.5*np.sin(2*a) - 0.05 * np.cos( 17*a + 0.8 ) + 0.05 * np.sin( 25 * a + 10 ) - 0.02 * \
np.cos( 45 * a + 0.3)
d = d / max(np.max(d), -np.min(d))
d = d - np.mean(d)
niter=100000
train_loss = np.zeros(niter)
#Set the bias to the forget gate to 5.0 as explained in the clockwork RNN paper
### Difference 1 ###
solver.net.params['lstm1'][1].data[15:30]=5
solver.net.blobs['clip'].data[...] = 1
#In order to address arbitrary size sequence.
for i in range(niter) :
seq_idx = i % (len(d) / 320)
solver.net.blobs['clip'].data[0] = seq_idx > 0 # 1 or 0 at head
solver.net.blobs['label'].data[:,0] = d[ seq_idx * 320 : (seq_idx+1) * 320 ]
solver.step(1)
train_loss[i] = solver.net.blobs['loss'].data
import matplotlib.pyplot as plt
# show loss
plt.plot(np.arange(niter), train_loss)
plt.show()
solver.test_nets[0].blobs['clip'].data[...] = 1
solver.test_nets[0].blobs['clip'].data[0] = 0
preds = solver.test_nets[0].forward()['ip1']
# show sin wave
plt.plot(np.arange(len(d)), preds)
plt.plot(np.arange(len(d)), d)
plt.show()
### Difference 2 ###
''' Official Caffe LSTM layer is not reshapable
solver.test_nets[0].blobs['data'].reshape(2,1)
solver.test_nets[0].blobs['clip'].reshape(2,1)
solver.test_nets[0].reshape()
solver.test_nets[0].blobs['clip'].data[...] = 1
preds = np.zeros(len(d))
for i in range(len(d)):
solver.test_nets[0].blobs['clip'].data[0] = i > 0
preds[i] = solver.test_nets[0].forward()['ip1'][0][0]
plt.plot(np.arange(len(d)), preds)
plt.plot(np.arange(len(d)), d)
plt.show()
'''
Christopherさんのブログに記載されているコードとは違う点が2点あります.それぞれコード中でDifference 1,Difference 2とコメントしています.
Difference 1
まず最初の異なる点は,forget gateのbiasの初期値を設定する部分です.
Caffe-LSTM
Caffe-LSTMでは実装上,solver.net.params['lstm1']のインデックスが[2]のデータが,各gateのバイアス項の値を含んでいます.
solver.net.params['lstm1'][2].data[15:30]=5
公式Caffe
一方,公式Caffeでは,solver.net.params['lstm1']のインデックスが[1]のデータが,各gateのバイアス項の値を含んでいます.
solver.net.params['lstm1'][1].data[15:30]=5
LSTMレイヤーでcellの数が15と設定済みであり,forget gateのバイアス項である15個の値はsolver.net.params['lstm1'][1 or 2].dataに対し2番目に格納されるので,Caffe-LSTM,公式Caffe共に~.data[15:30]と指定しています.
そもそもなぜforget gateのbiasの初期値を設定するかというと,LSTMの構造上,Back Propagationの際にどうしてもforget gateの値がcell stateから来る勾配(LSTMの要)に掛け算されるからです.もしforget gateの値が0に近い場合,Back Propagationの際に何度も0に近い値が掛け算されることになり,勾配消失が起こってしまいます.これを防ぐために,あらかじめforget gateのbiasの初期値を高めに設定することが有効なのです.(実際,forget gateのバイアス項の初期値を高めに設定しないとlossがまったく下がらず,Caffe-LSTMと公式Caffeの実装の差(インデックスの違い)に気づくのにかなり時間がかかりました.)
Difference 2
次に異なる点は,公式CaffeのLSTMはreshapeできない点です.Christopherさんのブログに記載されているコードでは,最終的にLSTMレイヤーへの入力データを320×1から2×1に変更しています.公式CaffeでLSTMレイヤーをreshapeしようとするとエラーを吐かれます.
#結果
##学習時のlossの変化(100000 iteration)
##波形の再現(緑:指定された波形,青:LSTMによって再現された波形)
#まとめ
- LSTMレイヤーへの入力はdata(T×N×...)とclip(T×N)の二つ
- clip_gradientsでGradient Clippingの閾値の設定
- forget gateのバイアス項へ高い初期値を設定して勾配消失を回避
#おわりに
動かすまでかなり大変でしたが,最終的に動かせたので自信が付きました.記事の需要的な話なのですが,最近ではCaffe以外のフレームワーク(Tensorflow, Chainer, PyTorchなど)でLSTMのチュートリアルは充実してるし,Caffe2とか出てくるしで,入門的なCaffeの記事そのものの需要は高くないと思いもしましたが,頑張って調べたので記念もかねてまとめました.