Monthly Hacker's Blog

毎月のテーマに沿ったプログラミング記事を中心に書きます。

ChainerのNStepLSTMでニコニコ動画のコメント予測。

はじめに

こんにちは、さかぱ(@zacapa_23)です。

先日、情報セキュリティスペシャリスト試験を受けてきました。これはIT系の国家資格試験、情報処理技術者試験の1つで合格率は16%前後です。
その試験会場の女性比率は学部4年に基本情報を受けた3年前に比べて、だいぶ上がったと思います。 綺麗なお姉さんがいると試験中もチラチラみてしまい、あんまり集中できません(カンニングはしてません)。


さて、本記事のテーマは前回のLSTMをNStepLSTMに書き換えることです。
www.monthly-hack.com

主に3つの点を取り上げます。

  • NStepLSTMの特徴
  • コードの違い
  • 未解決エラー

そんなことどうでもいい。とにかくNStepLSTMに触らせろ。という方はこちら。
github.com

NStepLSTMとは

NStepLSTMの特徴はLSTMの多層化可変長データのミニバッチができることです。

LSTMの多層化

次のようなLSTMをNStepLSTMで表現するとこんな感じ。dropoutも含んでいます。

import chainer.links as L
import chainer.functions as F

x = input_data

# LSTM
l1 = L.LSTM(n_in, n_out)
l2 = L.LSTM(n_out, n_out)
tmp = l1(F.dropout(x))
y = l2(F.dropout(tmp))

# NStepLSTM 
h = hidden_states
c = cell_states
l1 = L.NStepLSTM(n_layer=2, n_in, n_out, dropout_ratio)
y = l1(h, c, x)

しかし次のLSTMの場合、NStepLSTMでは表現できなさそうです。何故かと言うとNStepLSTMに渡すh, cのshapeは(n_layer, batchsize, n_out)なので、2層目以降の入力素子数はn_outになるからす。つまり素子数が全ての層で同じ(但し入力層を除く)になるようなLSTMしか表現できません*1

# LSTM (n_unit != n_out)
l1 = L.LSTM(n_in, n_unit)
l2 = L.LSTM(n_unit, n_out)
tmp = l1(F.dropout(x))
y = l2(F.dropout(tmp))

可変長データのミニバッチ

従来(Chainer 1.15.0以前)では最長のデータに合わせて0埋めをする必要がありましたが、NStepLSTMではそのまま入力することができます。

データ 従来 NStepLSTM
A 1 2 1 2 0 0 0 1 2
B 1 3 5 4 3 1 3 5 4 3 1 3 5 4 3
C 2 4 3 2 4 3 0 0 2 4 3

詳しくは、こちらの記事が参考になるかと思います。
studylog.hateblo.jp


また、次の文字や単語を予測するタスクでは、全ての系列データを1つに連結し、分割することがあります。

# D = A + B + C
D = [1, 2, 1, 3, 5, 4, 3, 2, 4, 3]

# m1 = D[:len(D)/2]
# m2 = D[len(D)/2:]
m1 = [1, 2, 1, 3, 5]
m2 = [4, 3, 2, 4, 3]

データのつなぎ目を表すために疑似ラベル(E=-1)を加えることもあります。

# D = A + E + B + E + C + E
D = [1, 2, -1, 1, 3, 5, 4, 3, -1, 2, 4, 3, -1]

# m1 = D[:len(D)/2]
# m2 = D[len(D)/2:]
m1 = [1, 2, -1, 1, 3]
m2 = [5, 4, 3, -1, 2]
...

こういった前処理をせずとも、NStepLSTMでは入力データをそのまま扱うことができます。

cudnnの利用

cuDNN 5以降でのcuDNN-RNNを使うことができ、こちらのほうが計算速度が速いそうです。
devblogs.nvidia.com

このあとで記述するのですが、Chainer 1.17.0ではNStepLSTMで(n_layer>=2)の場合、cudnn部分でエラーが発生してしまいます。つまりcudnnを使った多層LSTMができないということです。執筆時では修正版がリリースされてはいませんが、修正コードはissueに上がっています
github.com

コード解説

ソースコードはGithubで公開しています。前回の記事で使っているのがnico_lstm.py、今回のNStepLSTM実装はnico_nslstm.pyです*2
github.com

データセット

コメントの開始から終了までを予測するため、辞書に終了フラグを追加。各コメント"train"を辞書"vocab"を用いてid化。入力データ"train_now"と終了フラグを末尾に加えた次文字(正解)データ"train_next"をnumpy.arrayで用意します。一度numpy.arrayに変換するのはpermutationを利用するためで、ミニバッチは各コメントをnumpy or cupy配列に変換したlist型配列です。

with open('../sample_vocab.pkl', 'rb') as f:
    vocab = pickle.load(f)
    vocab.append('<EOF>')
    vocab2index = {word: i for i, word in enumerate(vocab)}

with open('../sample_texts.pkl', 'rb') as f:
    texts = pickle.load(f)
    train = [[vocab2index[word] for word in text]
             for text in texts if text != '']
train_now = np.asarray(train)
train_next = np.asarray([item[1:] + [vocab2index['<EOF>']] for item in train])

perm = np.random.permutation(n_train)
xs = [xp.asarray(item, dtype=np.int32)
      for item in train_now[perm[i:i + args.batchsize]]]
hx = chainer.Variable(
    xp.zeros((args.layer, len(xs), args.unit), dtype=xp.float32))
cx = chainer.Variable(
    xp.zeros((args.layer, len(xs), args.unit), dtype=xp.float32))
t = [xp.asarray(item, dtype=np.int32)
     for item in train_next[perm[i:i + args.batchsize]]]

モデル

NStepLSTMの引数は3つで、hs, cxは先述のデータセットで生成。xsはid化された各コメントを長さn_unitsのベクトルに変換したもの。L.EmbedIDを通すと勝手にchainer.Variable型になる。可変長ミニバッチなのでリスト内包表記を使い、各コメントをL.EmbedIDやL.Linearの層に通すのがポイントです。

  • hx (~chainer.Variable): Initial hidden states.
  • cx (~chainer.Variable): Initial cell states.
  • xs (list of ~chianer.Variable): List of input sequences.
class RNN(chainer.Chain):

    def __init__(self, n_layer, n_vocab, n_units, dropout, cudnn):
        super(RNN, self).__init__(
            embed=L.EmbedID(n_vocab, n_units),
            l1=L.NStepLSTM(n_layer, n_units, n_units,
                           dropout, use_cudnn=cudnn),
            l2=L.Linear(n_units, n_vocab),
        )

    def __call__(self, hx, cx, xs, train=True):
        xs = [self.embed(item) for item in xs]
        hy, cy, ys = self.l1(hx, cx, xs, train=train)
        y = [self.l2(item) for item in ys]
        return y

誤差計算

可変長ミニバッチに対応するために、for文を使って各コメント毎に誤差を計算する。

self.loss = None
self.y = self.predictor(*x, train)
for yi, ti in zip(self.y, t):
    if self.loss is not None:
        self.loss += self.lossfun(yi, ti)
    else:
        self.loss = self.lossfun(yi, ti)

* エラー
** CUDNN_STATUS_BAD_PARAM
n_step_lstmのパラメータがn_layer>=2のとき、<span style="color: #ff5252">cudnnでエラーが発生</span>します。
>|bash|
File "/anaconda3/lib/python3.5/site-packages/chainer/links/connection/n_step_lstm.py", line 96, in __call__
    train=train, use_cudnn=self.use_cudnn)
  File "/anaconda3/lib/python3.5/site-packages/chainer/functions/connection/n_step_lstm.py", line 468, in n_step_lstm
    ret = rnn(*inputs)
  File "/anaconda3/lib/python3.5/site-packages/chainer/function.py", line 197, in __call__
    outputs = self.forward(in_data)
  File "/anaconda3/lib/python3.5/site-packages/chainer/functions/connection/n_step_lstm.py", line 267, in forward
    self.reserve_space.data.ptr, reserve_size)
  File "cupy/cuda/cudnn.pyx", line 983, in cupy.cuda.cudnn.RNNForwardTraining (cupy/cuda/cudnn.cpp:13268)
  File "cupy/cuda/cudnn.pyx", line 1002, in cupy.cuda.cudnn.RNNForwardTraining (cupy/cuda/cudnn.cpp:13008)
  File "cupy/cuda/cudnn.pyx", line 321, in cupy.cuda.cudnn.check_status (cupy/cuda/cudnn.cpp:1718)
cupy.cuda.cudnn.CuDNNError: CUDNN_STATUS_BAD_PARAM: b'CUDNN_STATUS_BAD_PARAM'

拙い英語でChainer Githubにissueを投稿したところ、解決しました。ありがとうございます。
github.com

おわりに

今回はLSTMをNStepLSTMで書き直すということでした。最初はネットワークを書き換えれば平気かなと思っていたら、hやcの初期値を与えないとダメ。ミニバッチはchainer.Variableのリストじゃないとダメ。その後の全結合層を別々に計算しないとダメ。誤差関数もそれぞれやらないとダメ。といろいろ面倒でした。もしかしたら、もっとスマートな効率のいいコードがあるのかも知れません。何か指摘があればコメントにお願いします。大変なことが多かったけれども、1つ嬉しかったのは、エラーを見つけてchainerの改善に貢献できたことです。

ソースコード

github.com
github.com

*1:あくまでもソースコードからの推測

*2:Chainer Trainerでは実装していません。その予定もないです