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ぽいの。処理的には一応問題なさそう。
- README.mdのhierarchicalのつづり
- calculate_loss関数のoffset引数はpositionのほうが正しそう
- こっちはhttps://github.com/pfnet/chainer/pull/641で修正されているようです