はじめに
Chainerを使っていた人がPytorchを使おうとした時、LSTMで躓くことがあるらしいです。
ChainerとPytorchのLSTMの命名
LSTMの基本
まず非常に基本的なことなのですが、LSTMの出力というのは、前回の出力とセルの状態、そして現在の入力に依存します。
実装上は、
となっており、実際戻り値のと
が通常は全く同じものですが、
と
を保持しておいて、次回の入力にするという形でLSTMを構築します(ドロップアウトが入れば、
の方のみ適用されたりしますが、まあそれは応用上の話)。
手持ちデータの系列の長さが
ならば、
を逐次入れていくようなfor文を書けば、晴れてLSTMを実装できるというわけです(LSTMの内部の計算というのはどのフレームワークも提供してくれている)。
しかし、実のところLSTMには繰り返し値を入れて使うというのが当たり前であって、例えば系列の長さがLのデータを入れるならば
という操作が繰り返し行われるに決まっています。それならば最初から入力としてを受け取り、
を出力するような形での実装をしていても同じなのです(本来は無限に続く時系列データだとしても、バックプロパゲーションを有限で切らなければならないので、結局有限のシーケンスを入れることになります。したがって、学習時は例外なく上記の操作になるのです)。ちなみに隠れ状態とセル状態は最終時間
のみが出てきます。
PytorchもChainerも、どちらの方式でも実装が既に提供されています。
Chainerの場合
以下の方式
に対応するのはchainer.linksのLSTMです。通常L.LSTMと書かれています。(変換行列を自分で設定して与えたいならば、chainer.functionsの方を使うことになります)。こちらはデータをfor文で逐次与えていく形で実装を行います。バックプロパゲーションを打ち切るタイミングも自分で決めることができます。
そして以下の方式
に対応するのはchainer.linksのNStepLSTMです。
こちらの場合データを与えることで自動で系列長が決まるため、多層のLSTMを構築することも容易になっており、「NStep」というのはN層に引数を与えて拡張できますよということになります。
Pytorchの場合
以下の方式
に対応するのはtorch.nnのLSTMCellになります。
そして以下の方式
に対応するのはtorch.nnのLSTMです。
ChainerでLSTM使っていた人が、Pytorchで同じことをしたいならば、LSTMCellを使わなければなりません。ChainerのNStepLSTMに対応するのがPytorchではLSTMになります。
PytorchのLSTM
Chainerでも基本的にはNStepLSTMの利用が推奨されているかと思います。
Pytorchでも特にLSTMの操作をあれこれいじろうと思わない限り、LSTMCellではなくLSTMを使うことになると思われます。その際、Chainerに比べて人手で設定しなければならない部分が多いので、その助けになるようにサンプルコードをおいて置きます。
詳細はjupyter notebookの実行録とコメントを見ながら把握してください。
ここではザックリと。
まず隠れ状態とセル状態の初期化は自身で行わなければなりません。
隠れ状態のshapeは
(積層数, バッチサイズ, 隠れ層の特徴数)
ということになります。
積層数とはLSTMをどれだけ積んだか、その数です。
また、バッチサイズというのは入力に応じて変わります(例えばテストのときと訓練のときではバッチサイズは変わるでしょう。テストならば可能な限り大きなサイズを入れたいはずです)。したがって、順伝搬時に入力データのバッチサイズを調べ、初期状態を設定できるようにしています。
隠れ層の特徴数は自身で決定するので難しくありません。
セル状態と隠れ状態については、インスタンスを生成する際にも初期化を行っています(したがって、インスタンス生成時にバッチサイズを与えなければならない)。
この部分は「本質的には一繋がりの系列を、バックプロパゲーションのために分割しなければならなかった場合に、隠れ状態を保持するため」の機能を使うことを考慮しています(その場合は入力が入るごとに初期状態をリセットするわけにはいかない。ただし、大抵訓練データはシャッフルするため、そもそも系列を分割した時点で、それらが連続的に入力されるケースはないと思われる。また、初期状態がいい加減でもそれほど学習に影響が無いらしい…。笑)。
ただ、ノートブックではLSTMの働きを見るため、わざわざ前回の最終状態を初期状態として使うか否かをself.continue_seqで判断するようにしています。
仮にこの機能を排除するならば、インスタンス生成時にバッチサイズを必要としないように実装もできます。
入出力は、私自身がKerasで
(バッチサイズ、系列長、特徴数)
というデータにしておくことに慣れているため、そうなるように実装しています。
(系列長、バッチサイズ、特徴数)
で入出力を行いたい場合は
batchfirst=False
にしておき、forwardの最後を
h[:,-1,:]からh[-1,:,:]にしなければなりません。
bidirectional LSTMはbidirectional=Trueで設定できます。
この場合出力の特徴数が2倍になることに注意してください。
追記
この記事ではPytorchとChainerが提供するLSTM(あるいはNStepLSTM)の使い方に主眼をおいて文章を書きました。
これら既存の実装は使えるときには積極的に使ったほうが良いと思われますが、実際、手の込んだ(ホントはそんなに手が込んでなくても…)ことをやろうとすると意外と既存の実装をそのまま使えなくなります。
リカレントネットワークの勾配消失問題を解決したLSTMは、勾配消失を解決する方法として、より一般的に使われるようになっています。例えば木構造にLSTMを入れたTreeLSTMはとても有名だと思います(Chainerにはこちらの公式実装もいくつかある)。
TreeLSTMの場合、手持ちのデータが入る箇所がゴチャゴチャで、LSTMの出力の行き先もゴチャゴチャになります(これから使う計算グラフ(木構造)自体もデータが来るまで定まらない)。
また、計算グラフ自体が複雑というわけではなくとも、ドロップアウトを変分ベイズ推定という形で適用する際にも、PytorchのLSTM(ChainerのNStepLSTM)は(現状では)使えなくなってしまいます。
ベイズ推定の肝は、パラメータを確率変数として扱う(つまり背景に確率分布を仮定する)ことであり、この立場では、ドロップアウトというのは「パラメータを構成するベクトル(あるいは線形変換時の基底)がベルヌーイ分布によって確率的に零ベクトルになる」こととして捉えます(普通は単に出力が0になると考えると思います)。
つまり、LSTMの内部のパラメータが確率的に0になるという現象を再現する場合には、ある系列データの出力は時刻に関わらず全て共通の成分が0にならなければなりません(LSTMの内部の線形変換の基底自体が消えている)。
このことの説明は以下の記事の図が非常に分かりやすいです。
少なくともPytorchではそのようには実装されていません(出力の成分をランダムに0にするという作業が、各時刻に毎回呼び出されるため、毎回違う成分が0になってしまう)。たぶんChainerもそうだと思われます。
まあ細かいことは、基本的なことを抑えてからでも良いと思います(この変分ドロップアウトも劇的に性能上がるわけじゃなさそうですし)。
ただ、なんかオリジナルで細かいことやろうとしたときでも、やっぱPytorchは(TensorFlowに比べ)だいぶ扱いやすいです(というかTensorFlowムズい…)。