chainerで自然言語処理できるかマン

chainerで自然言語処理を勉強していくブログ

example\ptbを読む

examples\ptbに用意してあるコード(LSTMを使ったRNN言語モデル with dropoutはrecurrentじゃない部分だけに適用)を読んでいきたいと思います。 一応、1.6.1でもコードの変更は無いみたいですが、結果は1.5.0.2のやつです。

サンプル実行

# GPUで学習実行
$ python examples\ptb\train_ptb.py --gpu=0
#vocab = 10000
going to train 1812681 iterations
kern.cu
iter 10000 training perplexity: 996.19 (119.44 iters/sec)
iter 20000 training perplexity: 417.39 (121.77 iters/sec)
iter 30000 training perplexity: 300.75 (122.24 iters/sec)
iter 40000 training perplexity: 255.47 (121.69 iters/sec)
evaluate
epoch 1 validation perplexity: 237.56
...
epoch 38 validation perplexity: 120.29
learning rate = 0.0024379168015552267
iter 1770000 training perplexity: 42.36 (120.08 iters/sec)
iter 1780000 training perplexity: 48.21 (120.22 iters/sec)
iter 1790000 training perplexity: 46.21 (120.53 iters/sec)
iter 1800000 training perplexity: 44.88 (120.05 iters/sec)
iter 1810000 training perplexity: 40.86 (119.79 iters/sec)
evaluate
epoch 39 validation perplexity: 119.67
learning rate = 0.002031597334629356
test
test perplexity: 114.81662731578731
save the model
save the optimizer

個人PCについてるGPUで計算しても6時間ぐらいかかってしまいました。。。

他の言語モデルのperplexityはどの程度なんでしょう、、 参考まで、(同じデータじゃないと思いますが)、deeplearning2015:schedule | CILVR Lab @ NYUのWeek10part1のp.18を見てみると、Kneser-Ney 5-gramで141程度、Random forestで132程度という値が見えます。

学習のコード(train_ptb.py)

import chainer
from chainer import cuda
import chainer.links as L
from chainer import optimizers
from chainer import serializers

import net

word2vecのコードと比べて「serializers」が増えています。
コード中では「load_hdf5()」と「save_hdf5()」でmodelの重みやoptimizerの状態をHDF5形式で読み込んだり保存したりするために使われています。
HDF5はHierarchical Data Format5のことらしく、階層構造を含めてデータを保存できる形式です。
HDF5フォーマットに関するメモ書き - たまに書きます

HDFViewというものを利用すると、中身がどうなっているか見ることができます。(以下は生成されたrnnlm.modelの中身)
f:id:chainer_nlp_man:20151208231436p:plain

netは同じディレクトリ内に置かれたnet.pyで、RNNLMを定義しています。

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

単語埋め込み層(embed)と、LSTM層が2つ(l1,l2)と、全結合(出力)層(l3)で構成されています。
Standard Link implementations — Chainer 1.6.1 documentation
Standard Link implementations — Chainer 1.6.1 documentation
Standard Link implementations — Chainer 1.6.1 documentation
v1.5からChainでまとめて管理できるようになったらしく、こんな感じで継承クラスを作って管理するっぽいです。

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

LSTMは「状態」(メモリ的機能)を持つので、reset_state()では、LSTM層の状態をリセットできるようになっています。

    def __call__(self, x):
        h0 = self.embed(x)
        h1 = self.l1(F.dropout(h0, train=self.train))
        h2 = self.l2(F.dropout(h1, train=self.train))
        y = self.l3(F.dropout(h2, train=self.train))
        return y

__call__()は、順方向計算時に呼ばれる関数で、xを入力として、埋め込み層、LSTM層、LSTM層、全結合(出力)層の順に計算しています。
また、それぞれの層の間で、dropoutが入れてあり、確率pで0になっているようです。
Standard Function implementations — Chainer 1.6.1 documentation
前の論文の通り、non-recurrentな結合のところにだけdropoutが入れてあり、recurrentな結合(LSTMの前の出力を保持する内部変数h)にはdropoutは入れてありません。

n_epoch = 39   # number of epochs
n_units = 650  # number of units per layer
batchsize = 20   # minibatch size
bprop_len = 35   # length of truncated BPTT
grad_clip = 5    # gradient norm threshold to clip

各サイズなどの設定。
n_unitsは層ごとのユニット数で、650は先の論文だとmediumサイズらしいです。(largeは1500とか)
bprop_lenは、BPTTしたときtruncateする時間サイズを指定。
grad_clipは、後でGradientClipping()の引数にしてありますが、この関数は勾配のL2normの大きさがgrad_clipよりも大きい場合、この大きさに縮める処理を行うようです。
Optimizer — Chainer 1.6.1 documentation

# Prepare dataset (preliminary download dataset by ./download.py)
vocab = {}

全語彙を保管する用の辞書を準備。単語に対してIDを返すために利用しています。

def load_data(filename):
    global vocab, n_vocab
    words = open(filename).read().replace('\n', '<eos>').strip().split()
    dataset = np.ndarray((len(words),), dtype=np.int32)
    for i, word in enumerate(words):
        if word not in vocab:
            vocab[word] = len(vocab)   # 単語をIDに変換
        dataset[i] = vocab[word]       # datasetに単語IDを追加
    return dataset

train_data = load_data('ptb.train.txt')
valid_data = load_data('ptb.valid.txt')
test_data = load_data('ptb.test.txt')
print('#vocab =', len(vocab))

load_data()は、ファイルを読み込み、単語をIDにしつつdatasetに文を単語IDにしたものをいれていっています。
学習用、検証用、評価用を準備。

# Prepare RNNLM model, defined in net.py
lm = net.RNNLM(len(vocab), n_units)
model = L.Classifier(lm)
model.compute_accuracy = False  # we only want the perplexity
for param in model.params():
    data = param.data
    data[:] = np.random.uniform(-0.1, 0.1, data.shape)

net.pyのRNNLM定義を使ってインスタンスを生成。
L.Classifier()は、引数にpredictor(ここではRNNLM)を受け取って、入力値とラベルに対する損失(やAccuracy)を計算してくれる簡単な分類器クラスです。

Linkクラスはparams()を持っており、リンク階層のすべてのパラメータ(辺の重み)を返してくれています。
dataは配列を扱っているので、要素数分(data.shape)だけ-0.1~0.1の一様乱数で初期化しています。

# Setup optimizer
optimizer = optimizers.SGD(lr=1.)
optimizer.setup(model)
optimizer.add_hook(chainer.optimizer.GradientClipping(grad_clip))

SGDで最適化。lrは学習率(learning rate)。
GradientClipping()をフックに追加していますが、これは勾配のL2normの大きさが指定のサイズより大きい場合に小さくする関数。

# Init/Resume
if args.initmodel:
    print('Load model from', args.initmodel)
    serializers.load_hdf5(args.initmodel, model)
if args.resume:
    print('Load optimizer state from', args.resume)
    serializers.load_hdf5(args.resume, optimizer)

もし、初期値となるモデルや状態のファイルがあれば、それをロード。

def evaluate(dataset):
    # Evaluation routine
    evaluator = model.copy()  # to use different state
    evaluator.predictor.reset_state()  # initialize state

    sum_log_perp = 0
    for i in six.moves.range(dataset.size - 1):
        x = chainer.Variable(xp.asarray(dataset[i:i + 1]), volatile='on')
        t = chainer.Variable(xp.asarray(dataset[i + 1:i + 2]), volatile='on')
        loss = evaluator(x, t)
        sum_log_perp += loss.data
    return math.exp(float(sum_log_perp) / (dataset.size - 1))

evaluate()は、パープレキシティの計算を行っています。

# Learning loop
whole_len = train_data.shape[0]  # 学習データのサイズ
jump = whole_len // batchsize    # バッチサイズずつに分割した場合の幅
cur_log_perp = xp.zeros(())
epoch = 0
start_at = time.time()
cur_at = start_at
accum_loss = 0
batch_idxs = list(range(batchsize))
print('going to train {} iterations'.format(jump * n_epoch))

学習に使う各種変数を指定。

for i in six.moves.range(jump * n_epoch):
    x = chainer.Variable(xp.asarray( # バッチサイズ単位の単語を配列に入れる(入力用)
        [train_data[(jump * j + i) % whole_len] for j in batch_idxs]))
    t = chainer.Variable(xp.asarray( # xを1つずらした配列(出力用)
        [train_data[(jump * j + i + 1) % whole_len] for j in batch_idxs]))
    loss_i = model(x, t) # 損失を計算
    accum_loss += loss_i # 累計損失を計算
    cur_log_perp += loss_i.data

    if (i + 1) % bprop_len == 0:  # Run truncated BPTT  # bprop_len幅まで計算したら、まとめて勾配から最適化を行う
        model.zerograds()  # 勾配をゼロ初期化
        accum_loss.backward() # 累計損失を使って、誤差逆伝播(誤差の計算)
        accum_loss.unchain_backward()  # truncate # 誤差逆伝播した変数や関数へのreferenceを削除
        accum_loss = 0
        optimizer.update() # 最適化ルーチンの実行

    if (i + 1) % 10000 == 0: # 10000反復分終了したら、10000反復分でのパープレキシティを確認
        now = time.time()
        throuput = 10000. / (now - cur_at)
        perp = math.exp(float(cur_log_perp) / 10000)
        print('iter {} training perplexity: {:.2f} ({:.2f} iters/sec)'.format(
            i + 1, perp, throuput))
        cur_at = now
        cur_log_perp.fill(0)

    if (i + 1) % jump == 0: # 1エポック分終了したら、検証用データでパープレキシティを確認
        epoch += 1
        print('evaluate')
        now = time.time()
        perp = evaluate(valid_data)
        print('epoch {} validation perplexity: {:.2f}'.format(epoch, perp))
        cur_at += time.time() - now  # skip time of evaluation

        if epoch >= 6:  # 徐々に学習率を小さくしていっている
            optimizer.lr /= 1.2
            print('learning rate =', optimizer.lr)

    sys.stdout.flush()

学習のメインループ。終了条件は十分に収束したらじゃなく、指定回数実行したらになっています。

入力テキストが「ab...cd...ef」(それぞれのアルファベットは単語に対応)みたいな場合、jump幅分飛ばしで学習の入力データを準備。
その時のRNNへの入力xは、仮に「a」「c」「e」だったとすると、出力は次の単語が正解データtになってほしいので「b」「d」「f」になります。 複数の単語をまとめてmodel(x,t)で求めてみて、損失と勾配を計算します。

model()実行後は、RNNの内部状態が変化した上で1単語ずらして同様に学習します。
L.LSTMが前のそのLSTMの出力を保持するhという変数を内部に持つので、連続で計算していけば、RNNのBPTT的な学習がされるようです。
truncated BPTTという、BPTTの過去の時間を打ち切ってしまう方法は、backword()で逆誤差伝播したあとに、unchain_backward()を呼び出す事で過去のつながりをいったん切ることができるようです。

下のif文2つは定期的にパープレキシティを計算。

# Evaluate on test dataset
print('test')
test_perp = evaluate(test_data)
print('test perplexity:', test_perp)

学習が終了したら、evaluate()でテストデータに対してパープレキシティを求めます。

# Save the model and the optimizer
print('save the model')
serializers.save_hdf5('rnnlm.model', model)
print('save the optimizer')
serializers.save_hdf5('rnnlm.state', optimizer)

最後にRNNLMのモデルと状態を(hdf5形式の)ファイルに出力して終了しています。 1.6.1だとnpz形式のようです。

参考

Recurrent Nets and their Computational Graph — Chainer 1.6.1 documentation
Chainerチュートリアル -v1.5向け- ViEW2015