「Chainer による実践深層学習」の気づいたこといくつか


Chainer について書かれた数少ない本。
この9月に v2 対応版が出た。が、v3 リリース秒読みの時期に……というツラミはある*1
深層学習ライブラリは現状「泳ぎ続けなければ死ぬ」(アップデート止まったら、終わったのかな? とか思っちゃう)ので、宿命的にしょうがないのかな。


社内でこの本の読書会とかしており、ちょいちょい間違いを見つけてしまう。
細かいのはもういいかなとは思うんだが(全部書いてたら正直キリがない)、せっかくの Chainer 本、読者が誤解すると事故が起きそうなちょっと大きめの間違いを放置するのはもったいないので、ここにメモしておく。
自分で読んだのは RNN 以降なので、主にその範囲。


以下、章・ページやリスト名などの表記は v1 版だが、v2 版でも残っていれば特定は難しくないと思う。
手元にあるのは v1 版で、v2 版では直ってるかもしれない。が、ちら見した感じでは残ってそうだった……。
ちなみに著者の新納先生のサイトに Erratta も出ているので参考に。見つけた間違いはほとんど載っていないが……。

7.7 Chainer による LSTM の実装(p98-99)


LSTM をあえて基本的な部品だけで実装する、本書の白眉。


lstm0.py のパラメータ宣言部で、式 Wx+Rh+b を実装するのに W 用の L.Linear と R 用の L.Linear を宣言しているが、両方がバイアス項を持つため、ダブってしまっている。Wx+Rh+a+b という状態。
致命的な悪さはおそらくしないだろうが、メリットは何もない*2ので、片方に nobias=True をつけて、バイアス項を一本化するべきだろう。
学習時間も 5% ほど短くなる。

class MyLSTM(chainer.Chain):
    def __init__(self, v, k):
        super(MyLSTM, self).__init__(
            embed = L.EmbedID(v, k),
            Wz = L.Linear(k, k, nobias=True),
            Wi = L.Linear(k, k, nobias=True),
            Wf = L.Linear(k, k, nobias=True),
            Wo = L.Linear(k, k, nobias=True),
            Rz = L.Linear(k, k),
            Ri = L.Linear(k, k),
            Rf = L.Linear(k, k),
            Ro = L.Linear(k, k),
            W = L.Linear(k, v),
        )

7.7 Chainer による LSTM の実装(p100)

また学習の部分は素の RNN のものと同じでよいのですが、計算時間がかなりかかります。RNN の学習には unchain_backward() という関数を使うことで改善されます。これは長い系列を学習する際に、古い情報を捨てて、計算時間を改善するのに使います。ここでは文の長さが 30 を超える場合に、この関数を起動することにします。


と記述されているが、残念ながらここでの使い方では unchain_backward は全く効果がない。
本書の lstm0.py で unchain_backward を入れたり削ったりしたときに性能が変わったように見えたとしても、それはおそらく初期値の乱数の影響である。


unchain_backward は、L.LSTM のような「前時刻の隠れユニットや記憶の状態をメンバーとして保持しておき、各時刻ごとに __call__ を呼ぶ」タイプの実装を前提として設計されている。RNN のグラフの横方向の辺を切って、計算グラフを小さくするために、例えば 30 時刻ごとに unchain_backward を発行する。加えて、公式のサンプルを含め、多くの LSTM 実装では「1系列=全データをつなげたもの」なので、切らないと大変なことになる。


しかし、本書の lstm0.py は「__call__ には系列全体を渡す。隠れユニットや記憶の状態は __call__ の先頭でローカル変数として宣言&初期化する」タイプの実装となっている。したがって、前の __call__ のあとに unchain_backward を発行しようがしまいが、次の __call__ が呼ばれたときは計算グラフは切れている。


ちなみに本書では「1系列=1文」であり、truncate しなくても死なない。が、隠れユニットが 100 次元しか無いので、適切に切ったほうがおそらく性能は高くなるだろう。
しかし lstm0.py で truncate したいと思っても、学習ループ(特に backward 発行)と時刻のループが別れてしまっているので、簡単な改造では難しそう。同等ではないが、 __call__ の中で、30時刻ごとに h.unchain() と c.unchain() を発行すれば、近いものになるのかな?

8.5 Attention の導入(p123)


Encoder の各時刻の隠れベクトルを Decoder に渡すためのリスト gh を作る部分の説明。

リスト gh に Encoder 側の \tilde{h}_i を順番に追加しています。
(中略)
gh に \tilde{h}_i を追加する際に、明示的に np.copy を使っていますが、おそらく必要ありません。そのまま ht.data[0] を渡しても問題ないとは思いますが、念のためコピーしました。


コピーしちゃダメーーー!!!


.data やそのコピーではなく Variable のまま渡さないと、せっかくの計算グラフが切れてしまう。
Decoder に渡す変数(もともとの Attention では隠れユニットを渡すが、最近のモデルでは Encoder の出力を渡すのが流行っている?)の計算グラフを切ってしまうと、「Attention にも有効なように Encoder が符号化してくれる」という Attention の嬉しさの一つが消えてしまう。


というわけで、コピーしないで、Variable のまま渡しましょう。

*1:ちなみに v1 版も、Chainer v2 が出た後の出版、しかも v1.10 準拠だった……

*2:aとbが最適解を持たないというデメリットはある