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

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

examples\word2vecを読む

examples\word2vecに用意してあるコードを読んでいきたいと思います。

word2vecとは

いろんな意味で使われているような気がしますが、正確には
word2vec - Tool for computing continuous distributed representations of words. - Google Project Hosting
のプログラムのことを指すと思います。
Mikolovら(2013)によって、単語の分散表現(複数の計算要素で表現するもの。ベクトルとか)を高速に得る手法が提案、ツールが公開され、得られた分散表現で単語の足し算・引き算ができたり、単語の類似性があったりで話題になっていました。
岡崎先生の分散表現に関する資料:単語の分散表現と構成性の計算モデルの発展

word2vecでは、ネットワークの形として「Continuous Bag of words(CBOW)」と「SkipGram」、高速化(?)に「Hierarchical softmax」と「Negative sampling」などの方法を取り入れており、このサンプルではこの4つが実装されているようです。
海野さんのword2vecの論文紹介資料:unnonouno: NIPS2013読み会でword2vec論文の紹介をしました

サンプル実行

まずはREADME.mdにある通りに実行してみます。

# ファイルダウンロード
$ python examples\ptb\download.py

# GPUで学習実行
$ python examples\word2vec\train_word2vec.py --gpu=0
GPU: 0
# unit: 100
Window: 5
Minibatch-size: 100
# epoch: 10
Training model: skipgram
Output type: hsm

n_vocab: 9999
data length: 887521
epoch: 0
kern.cu
100000 words, 27.32 sec, 3660.37 words/sec
200000 words, 6.55 sec, 15261.06 words/sec
300000 words, 6.77 sec, 14763.82 words/sec
400000 words, 7.06 sec, 14158.29 words/sec
500000 words, 7.02 sec, 14253.04 words/sec
600000 words, 6.83 sec, 14650.08 words/sec
700000 words, 6.66 sec, 15013.11 words/sec
800000 words, 6.73 sec, 14856.37 words/sec
41296432.0
epoch: 1
900000 words, 6.65 sec, 15039.67 words/sec
1000000 words, 6.64 sec, 15049.58 words/sec
1100000 words, 6.59 sec, 15182.98 words/sec
1200000 words, 6.64 sec, 15058.11 words/sec
1300000 words, 6.77 sec, 14773.31 words/sec
1400000 words, 6.64 sec, 15055.18 words/sec
1500000 words, 6.73 sec, 14858.73 words/sec
1600000 words, 6.62 sec, 15102.17 words/sec
1700000 words, 6.67 sec, 15003.61 words/sec
30504294.0
...
epoch: 9
8000000 words, 6.87 sec, 14558.01 words/sec
8100000 words, 6.65 sec, 15035.03 words/sec
8200000 words, 6.57 sec, 15209.29 words/sec
8300000 words, 6.53 sec, 15319.69 words/sec
8400000 words, 6.59 sec, 15174.44 words/sec
8500000 words, 6.69 sec, 14952.80 words/sec
8600000 words, 6.65 sec, 15048.00 words/sec
8700000 words, 6.57 sec, 15226.57 words/sec
8800000 words, 6.76 sec, 14793.76 words/sec
27516834.0

download.pyでは、「ptb.train.txt(学習用)」「ptb.valid.txt(検証用)」「ptb.test.txt(評価用)」の3ファイルをダウンロードしています。 このファイルは、LeCun先生のニューヨーク大学での2015年の講義で使っているデータのようです。

# ptb.train.txtの先頭3行
 aer banknote berlitz calloway centrust cluett fromstein gitano guterman hydro-quebec ipo kia memotec mlx nahb punts rake regatta rubens sim snack-food ssangyong swapo wachter 
 pierre <unk> N years old will join the board as a nonexecutive director nov. N 
 mr. <unk> is chairman of <unk> n.v. the dutch publishing group 

train.txtの最初の3行を見てみると、英語ではないものからスタートしているようですが、ほぼ英語っぽいように見えます。
スペース区切りで、大文字は小文字に変換、数字が「N」、未知語相当が「<unk>」に置き換えられているみたいです。
trainは42068行、validは3370行、testは3761行ありました。(ここではtrainのみ使用されます)

train_word2vec.pyでは、word2vecの学習が行われ、「word2vec.model」ファイルに単語の分散表現が出力されています。

# 類似単語の検索
$ python examples\word2vec\search.py
>> group
query: group
inc.: 0.46817001700401306
laurel: 0.44689810276031494
blackstone: 0.41681644320487976
wpp: 0.4130145311355591
giant: 0.4069499671459198
>> director
query: director
president: 0.5530474185943604
vice: 0.5511615872383118
chairman: 0.49361783266067505
j.: 0.4811353385448456
chief: 0.46405717730522156
>> year
query: year
week: 0.7283791899681091
month: 0.6891863942146301
summer: 0.5073532462120056
earlier: 0.5011518001556396
spring: 0.37471672892570496
>> four
query: four
five: 0.7502017021179199
three: 0.6845633387565613
two: 0.6798515319824219
six: 0.6025211215019226
several: 0.5378302335739136

search.pyで、上記のモデルファイル(学習された単語の分散表現)を読み込んで、入力された単語にベクトルが近い文字列を5件表示してくれています。
似た種類の単語が並んででうまく学習されているようです。

学習のコード(train_word2vec.py)

ダラダラと読んでいきます。わかっていないことが多いので、間違っている可能性が高いです。
また、Chainerの使い方と 自然言語処理への応用を参考にしながら見ていきます。

import numpy as np

import chainer
from chainer import cuda
import chainer.functions as F
import chainer.links as L
import chainer.optimizers as O

必要モジュールのimport。chainerの基本セットのようです。

index2word = {}
word2index = {}
counts = collections.Counter()
dataset = []

index2wordとword2indexは単語文字列とIDとの相互変換に使われます。
countsは、出現頻度を数えるために使われます。
datasetは、IDのリストとして使われます。

with open('ptb.train.txt') as f:
    for line in f:
        for word in line.split():
            if word not in word2index:
                ind = len(word2index)
                word2index[word] = ind
                index2word[ind] = word
            counts[word2index[word]] += 1
            dataset.append(word2index[word])

ファイルから1行ずつ読み込んで、単語のID化と頻度数え上げ、データセットの構築を行っています。
これを見る限りだと、改行が無視されるので、入力テキストは長い1文として扱われるようです。

n_vocab = len(word2index)

語彙の種類数がn_vocabに保存されます。

if args.out_type == 'hsm':
    HSM = L.BinaryHierarchicalSoftmax
    tree = HSM.create_huffman_tree(counts)
    loss_func = HSM(args.unit, tree)
elif args.out_type == 'ns':
    cs = [counts[w] for w in range(len(counts))]
    loss_func = L.NegativeSampling(args.unit, cs, 20)
elif args.out_type == 'original':
    loss_func = SoftmaxCrossEntropyLoss(args.unit, n_vocab)
else:
    raise Exception('Unknown output type: {}'.format(args.out_type))

コマンドライン引数に応じて、損失関数の種類を変えているようです。
HierarchicalSoftmaxとNegativeSamplingはchainer.linksモジュールにあるようで、単純なSoftmaxCrossEntropyLossのみこのコード内に定義されています。

class SoftmaxCrossEntropyLoss(chainer.Chain):
    def __init__(self, n_in, n_out):
        super(SoftmaxCrossEntropyLoss, self).__init__(
            W=L.Linear(n_in, n_out),
        )

    def __call__(self, x, t):
        return F.softmax_cross_entropy(self.W(x), t)

といっても、chainer.Chainを継承したクラスとして定義され、chainer.functionsで定義済みのsoftmax_cross_entropy()を利用して実装されているようです。
functionオブジェクトは、「演算ノード」に相当し、上の場合は損失関数の計算に使用されています。(ここではネットワークの最終的な出力と正解データとの差分的なもの)

if args.model == 'skipgram':
    model = SkipGram(n_vocab, args.unit, loss_func)
elif args.model == 'cbow':
    model = ContinuousBoW(n_vocab, args.unit, loss_func)
else:
    raise Exception('Unknown model type: {}'.format(args.model))

コマンドライン引数に応じて、ネットワークの形をmodel変数に定義しています。

class ContinuousBoW(chainer.Chain):

    def __init__(self, n_vocab, n_units, loss_func):
        super(ContinuousBoW, self).__init__(
            embed=F.EmbedID(n_vocab, args.unit),
            loss_func=loss_func,
        )

    def __call__(self, x, context):
        h = None
        for c in context:
            e = self.embed(c)
            h = h + e if h is not None else e

        return self.loss_func(h, x)

F.EmbedID関数は、単語をIDで表現した密ベクトルを実装したものらしいです。
(イメージ的には、1-of-KのIDから内部ノードへのエッジすべて、を作っているようです)
call関数では、context情報を受け取ったらそのIDに対応する密ベクトル情報eを内部ノードの状態hに加算し、その合計値とxとの損失を返しています。

class SkipGram(chainer.Chain):

    def __init__(self, n_vocab, n_units, loss_func):
        super(SkipGram, self).__init__(
            embed=L.EmbedID(n_vocab, n_units),
            loss_func=loss_func,
        )

    def __call__(self, x, context):
        loss = None
        for c in context:
            e = self.embed(c)

            loss_i = self.loss_func(e, x)
            loss = loss_i if loss is None else loss + loss_i

        return loss

一方、SkipGramでは、call関数で、xからcontextを予測することになるので、contextの各単語cの密ベクトル情報eとの損失を計算し、合計したものを返しています。

dataset = np.array(dataset, dtype=np.int32)

データセットをnumpyの配列に直しています。内部処理の都合のようです。

optimizer = O.Adam()
optimizer.setup(model)

最適化の手法を設定しています。
Adamは勾配の更新幅を自動調整する手法の一つで、echizen_tmさんの30minutes Adamで30分でわかります。

skip = (len(dataset) - args.window * 2) // args.batchsize
for epoch in range(args.epoch):
    indexes = np.random.permutation(skip)       # 0~skip-1までの順列をランダムに入れ替えたもの
    for i in indexes:
        position = np.array(range(0, args.batchsize)) * skip + (args.window + i)    # 適当な位置から均等にbatchsize個分の位置情報
        loss = calculate_loss(model, dataset, position)

        model.zerograds()    # 勾配をゼロに初期化
        loss.backward()      # 逆伝播を実行
        optimizer.update()   # 最適化ルーチンを実行

処理のメイン部分。(いくつか省略)
datasetの中の単語を複数まとめて処理するために、positionリストにランダム位置から均等にbatchsize個の位置情報をいれておいているようです。
まとめてcalculate_loss関数で損失を計算し、後半のOptimizer更新処理(お約束処理らしい)を実行しています。

def calculate_loss(model, dataset, position):
    # use random window size in the same way as the original word2vec
    # implementation.
    w = np.random.randint(args.window - 1) + 1
    context = []
    for offset in range(-w, w + 1):    # -wからwまで
        if offset == 0:   # 注目している単語自体は文脈には含めない
            continue
        c_data = xp.asarray(dataset[position + offset])   # positionのoffsetだけずらしたものを
        c = chainer.Variable(c_data)                      # Variableオブジェクトに変換。
        context.append(c)                                 # contextリストに入れておく。
    x_data = xp.asarray(dataset[position])                # 注目している単語もリスト化し、
    x = chainer.Variable(x_data)                          # Variableオブジェクトに変換。
    return model(x, context)                              # modelの実行結果(損失合計)を返す。

word2vecの元のプログラムではwindow幅が固定でなくランダムのようでした。
ここでも、それに合わせるため、wをランダムに変えているみたいです。 Variableはデータノードに相当し、実際の「値」を保持するオブジェクトのようです。
chainerの内部処理をするために、Variableオブジェクトに変換していますが、リストのままでまとめて処理できるようですね。

with open('word2vec.model', 'w') as f:
    f.write('%d %d\n' % (len(index2word), args.unit))
    w = model.embed.W.data
    for i in range(w.shape[0]):
        v = ' '.join(['%f' % v for v in w[i]])
        f.write('%s %s\n' % (index2word[i], v))

最後に、word2vec.modelファイルへ学習した密ベクトル情報を出力して終了。

だいーーーーぶ、見やすいですね。(word2vecのプログラムと比較して・・・)
基本の処理は分かった気になりましたが、やはりchainerのチュートリアルから進めたほうがよさそうでした。。

メモ

typoぽいの。処理的には一応問題なさそう。