word2vec を青空文庫で試してみる
word2vec は単語のベクトル表現をえるための手法の一つで、ニューラルネットワークを利用して行われているものです。 登場した当時の他の単語ベクトル生成手法に比べ高速に、そして単語関係の表現能力が高い獲得できる点がポイントです。 CBOWとskip−gramの2つのアルゴリズムが提案されていて、今回はCBOWを使ってみました。
CBOWは、注目する単語の周辺 N 語を入力すると、注目する単語にカテゴライズするように学習します。 ニューラルネットワークの形としては、やや変則的ですが、入力層がNxV(Vは単語数)、出力層がVの2層のニューラルネットワークになります。 入力層から隠れ層への重み行列は単語ごとに共通で、単語表現を合計した後、V次元の入力層に入力するのと同じになります。 入力層や出力層での単語表現は、IDの割り当てになります。その単語を表現する次元は1、それ以外は0になっているベクトルです。 一方、学習の結果得ることができる単語ベクトルは、入力層から隠れ層への重み行列を用います。 このことにより、学習器が獲得した内部表現を利用することができるようになります。
ちなみに今回は、chainer のword2vecサンプルではなく、C言語実装のword2vecを利用しました。 理由としては、単純にこっちの方が非常に高速だからです。こちらのブロク記事で一目瞭然ですが、C言語実装のword2vecはCPUだけで非常に高速に動作します。 C言語で実装されているということと、頻出語の学習を極力行わないようにするサブサンプリングという処理を行っているのが大きいのだろうと思います。
データには、青空文庫のテキストデータを使用しました。特定の作家の作品を全て落としてきて、青空文庫用の特殊記法やヘッダー、フッターを以下のようなコードで削って利用しました。 また、テキストを分かち書き形式にしておく必要がありますが、それにはmecabを利用しています。
import re import argparse # 文中の特殊記号の正規表現 replace_regex = [ re.compile("《.+?》"), re.compile("|"), re.compile("[#.+?]"), re.compile("〔.+?〕") ] def aozora2txt(input, output): f_in = open(input, 'r', encoding='shift_jis') f_out = open(output, 'a', encoding='utf-8') # 本文推定用 start_line = "-------------------------------------------------------" end_word = "底本:" start_line_count = 0 for line in f_in: # 終了と開始を判定する if line.find(end_word) > -1: break if line.find(start_line) > -1: start_line_count += 1 continue if start_line_count < 2: continue for regex in replace_regex: line = regex.sub('', line) # 本文中の特殊記号を除去 f_out.write(line) f_in.close() f_out.close()
作家は、夏目漱石と太宰治。類似単語を検索する単語には、人生、愛。 パラメーターによる結果比較のため、出力するベクトルの次元数と、サブサンプリングに利用する値を変えてみています。ベクトルの次元数を変えることで獲得される内部表現の形が、サブサンプリングに利用する値を変えることで頻出語の扱われ方がそれぞれ影響を受けます。
- 人生
ランク | ベクトル100 サンプリング1e-2 |
ベクトル200 サンプリング1e-2 |
ベクトル100 サンプリング1e-4 |
ベクトル200 サンプリング1e-4 |
---|---|---|---|---|
1 | 人世 | 人世 | 触れろ | 意義 |
2 | 自己 | 宇宙 | 意義 | 人世 |
3 | 宇宙 | 人格 | 美的 | 触れろ |
ランク | ベクトル100 サンプリング1e-2 |
ベクトル200 サンプリング1e-2 |
ベクトル100 サンプリング1e-4 |
ベクトル200 サンプリング1e-4 |
---|---|---|---|---|
1 | 人間 | 奇蹟 | ドラマ | ドラマ |
2 | 愛 | 青春 | 做 | 做 |
3 | 行為 | 行為 | 現実 | 青春 |
- 愛
ランク | ベクトル100 サンプリング1e-2 |
ベクトル200 サンプリング1e-2 |
ベクトル100 サンプリング1e-4 |
ベクトル200 サンプリング1e-4 |
---|---|---|---|---|
1 | 恋 | 恋 | 弄ぶ | 対象 |
2 | 情 | 個々 | 純潔 | 深刻 |
3 | 宇宙 | 宇宙 | 信念 | 恋 |
ランク | ベクトル100 サンプリング1e-2 |
ベクトル200 サンプリング1e-2 |
ベクトル100 サンプリング1e-4 |
ベクトル200 サンプリング1e-4 |
---|---|---|---|---|
1 | 美 | 美 | 遂行 | 遂行 |
2 | 正義 | 愛情 | 異性 | 異性 |
3 | 愛情 | 信実 | 愛情 | 表現 |
結果としては、作家と頻出語の扱いの違いが、類似語の探索結果に大きく影響を与えているようです。思ったより出力次元数には影響がないようですね。一作家の文章だけなので全体として語彙が少なめ(一万五千語くらい)だからかもしれません。
Chainer の imagenet サンプルで遊んでみる
Deep Learning といえば、やはり画像認識での利用です。今さら感もないではないですが、chainer の imagenet サンプルは、日々進歩している画像認識処理を様々な形のネットワークで試してみることができ、Deep Learning の仕組みがどのようになっているか、chainer でオリジナルのネットワークを組み立てるにはどのように記述したらいいか、概要を把握するにはぴったりのサンプルだと思います。 今回は、chainer 1.7.1 に付属している imagenet サンプルのうち NIN ネットワークサンプルを利用して画像認識を行ってみました。 画像データセットとして、こちらの256 Object Categoriesを使用しました。
Deep Learning で画像認識を行う場合、一般的に畳み込みニューラルネットワーク(CNN)と呼ばれるものを用います。ネットワークの名前にもなっている畳み込み処理ですが、Deep Learning 特有の処理ではなく、画像処理として一般的に用いられているフィルタ処理のことを指します。このフィルタ処理は、数学的には行列演算として表現することができ、前層の出力に重みをかけ合計することにより次層の入力とする、ニューラルネットワークの演算処理でそのまま表現することができます。 CNN では、学習によりこの重みが更新される、つまり画像を処理するためによりよいフィルタを獲得していくことが成果となります。また、フィルタは何層にも重ねることができ、フィルタの出力結果に対するフィルタといったより複雑な特徴抽出を行うことができます。
このフィルタ処理の結果は、似たような画像であっても微妙な違いにより出力にムラが出ることがあります。そのムラを除去するために用いられるのがプーリング層で、出力結果を安定させる効果があります。この層は、特定区間の平均や最大を出力とする固定の操作が行われるため、学習による重みの更新を行う必要はありません。プーリング層は、一般的に畳み込み層の直後に置かれます。
今回使用したNINは、畳み込み層として、MLPConv と呼ばれる非線形な畳み込み層を使用しています。具体的には、3つの畳み込み層の後に1つのプーリング層があるという構造になります。NINは、このMLPConv層を4つつなげることにより、複雑な特徴表現を学習するようなネットワークになっています。
imagenet サンプルでは、学習データの用意は自分で実装する必要があります。画像は適当な画像データベースからおとしてくれば良いですが、学習に用いるデータセットのリストの作成と、学習データを256x256サイズにリサイズしておく必要もあります。今回は、ラベル毎にランダムで、80%を学習用データ、残りを検証用データとして扱うようにしました。また、縦横それぞれが256ピクセルになるように拡大・縮小することにより、画像をCNNで扱えるようにリサイズしています。
import os import argparse import random from PIL import Image parser = argparse.ArgumentParser(description='Image net dataset create') parser.add_argument('--root', '-r', default='.', help='image files root path') parser.add_argument('--output', '-o', default='Images', help='output root path') args = parser.parse_args() train_list_file = open('train_list.txt', mode='w', encoding='utf-8') validate_list_file = open('validate_list.txt', mode='w', encoding='utf-8') train_rate = 0.8 # 80% の確率で学習用データとする if not os.path.lexists(args.output): os.mkdir(args.output) directories = os.listdir(args.root) for i, directory in enumerate(directories): full_dir_path = os.path.join(args.root, directory) if not os.path.isdir(full_dir_path): continue for file_name in os.listdir(full_dir_path): try: # Resize 256x256 input_path = os.path.join(full_dir_path, file_name) if not os.path.isfile(input_path): continue path = os.path.join(args.output, file_name) Image.open(input_path).resize((256, 256), Image.LANCZOS).convert("RGB").save(path) label = directory.split(".")[0] line = path + " " + label + "\n" if train_rate > random.random(): train_list_file.write(line) else: validate_list_file.write(line) except: print(file_name, "is not image") train_list_file.close() validate_list_file.close()
ちょっと前置きが長くなりましたが、早速学習結果を見てみましょう。まず、30エポック学習させた結果どのような画像を認識できるようになったか見てみます。ラベルが256ありますが、そのうち野球用バッドの画像セットについての結果です。バッドといえば言わずもがなこんな感じの画像が含まれています。
一方で、下のようなバッド以外のものも混じった意地悪なデータも学習セットにはだいぶ含まれています。
このような画像を認識した結果、基本的に学習セットにあるデータに関しては、バッドと認識するようになりました。混合画像までバッドと認識してしまうのはちょっと考えものですが、そのように教え込んでいるので仕方ありません。一方、未学習データに関しては下の一番目や三番目の画像はバッドと認識してくれますが、二番目の画像は残念ながらバッドとしては認識してくれませんでした。なんとなくですが、向きが関係してるのではないかと予想しています。
次に、識別精度を見てみます。比較として同じデータで別に学習し、10エポックで学習を終了したモデルの結果も掲載します。見ての通り、学習用データにおいても、検証用データにおいても、30エポックまで学習したモデルの方が識別精度が良くなっています。 学習用データセットの識別精度は大幅に上昇しており、ほとんどの画像を正しくラベリングできるようになっています。しかし、先ほどのバッド例のように、他の物体も混じっている画像なども多く含まれているため、学び方としてはあまり良くないように思われます。 検証用データの精度があまり上昇していなことからもそのあたりは読み取っていただけるかなと思います。もうちょっとスクリーニングされているデータセットでないと、適当にやるには厳しかったかもしれません。
30 epoch result: train accuracy: 0.926514966153 validate accuracy: 0.422843056697 10 epoch result: train accuracy: 0.390098686893 validate accuracy: 0.315529991783
最後に、上で比較したそれぞれのモデルの第一層の重みを可視化したものを比較してみました。左がエポック10まで学習したもの、右がエポック30まで学習したものとなります。なんとなくですが、右の方が濃淡がはっきりしているように見えます。しっかり特徴抽出を行えるようになっているということですね。ここまで自動で識別できることを考えると、多少複雑な識別問題でもそれなりのデータセットを用意すれば的確に認識してくれそうです。
Chainer の ptb サンプルで遊んでみる
AWS の GPU環境をなんとか整えたので、RNN で遊んでみようと思い、Chainer の ptb サンプルを試しに動かしてみました。 ptb サンプルでは、入力された単語系列(文章)を元に、次の単語を推論する構造で、RNNのよくあるモデリングになっています。 ちなみに前回までに入力された単語(ようするに文脈)を覚えておく構造にはLSTMという特殊な層を利用しています。 また、入力層と出力層にはそれぞれの単語に相当するユニットが存在します。語彙が一万個あればそれぞれ一万ユニット必要で、学習時にユニットが割り当てられていない単語については入力も出力も行うことができません。
このRNNの学習はなかなかに遅く、今回は epoch5 くらいまで学習させてみましたが、GPU機で軽く2時間はかかりました。 g2.xlarge インスタンスは1時間100円するので、学習が終わったら自動で停止してくれるギミックを作っておかないとオチオチ夜も寝られません。
今回は、学習はリモートのGPU環境、テストデータの結果出力はローカルのCPU環境で行いました。 サンプルコードは Chainer 1.7.1 に付属のものを使用しています。 ちなみに、結果出力用のコードは下のような感じです。
import numpy as np import six import chainer from chainer import cuda import chainer.links as L from chainer import optimizers from chainer import serializers import net def load_data(filename): global vocab global inv_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) inv_vocab[len(vocab)-1]=word # 単語逆引き用辞書 dataset[i] = vocab[word] return dataset vocab = {} inv_vocab = {} train_data = load_data('ptb.train.txt') train_data = load_data('ptb.train.txt') test_data = load_data('ptb.test.txt') print('#vocab =', len(vocab)) # 学習済みモデル取り込み model = L.Classifier(net.RNNLM(len(vocab), 650)) serializers.load_npz('rnnlm.model', model) model.predictor.reset_state() # LSTM層の初期化 for i in six.moves.range(10): data = test_data[i:i+1] print(inv_vocab[data[0]]) x = chainer.Variable(data, volatile=False) h0 = model.predictor.embed(x) h1 = model.predictor.l1(h0) h2 = model.predictor.l2(h1) y = model.predictor.l3(h2) # 出力結果のうち上位3個を出力 prediction = list(zip(y.data[0].tolist(), inv_vocab.values())) prediction.sort() prediction.reverse() for i, (score, word) in enumerate(prediction): print('{},{}'.format(word, score)) if i >= 2: break
上で書いたように、RNNの学習はそれまでにどんな値を入力されたかどうかで変化します。 今回は、サンプルのテスト用データ内の「no it was n't black monday」という文を使用して、毎回LSTM層のパラメーターをリセットした文脈を無視した場合と、最初の単語の時のみLSTM層のパラメータをリセットした文脈を考慮した場合でそれぞれ結果を比べてみました。
- 文脈無視
no longer,10.33497428894043 doubt,8.990683555603027 way,8.030651092529297 it is,10.528656005859375 will,9.515053749084473 has,9.51209831237793 was a,8.827898979187012 <unk>,8.493036270141602 the,7.862812042236328 n't <unk>,7.8151350021362305 yet,6.953648090362549 <eos>,6.921860694885254 black <unk>,9.096553802490234 and,8.121747016906738 <eos>,7.601911544799805 monday <eos>,10.35195541381836 's,9.545741081237793 and,8.740907669067383 <eos> the,9.107492446899414 <unk>,7.814574241638184 but,7.787456512451172
<eos> は End Of State の略で文の終わりを意味します。<unk> は Unknown の略で未知語を意味し、今回学習に用いたサンプルデータでは、特定の人名に相当します。 最初の単語の no を見てみると no longer, no doubt, no way など慣用句的に使用されるような単語が出力されており、うまいこと学習できていることがわかります。他の単語を見ても、文章のどこかには出てきそうな組み合わせばかりで、次の単語を推測するというタスクの結果としては悪くないもののように思えます。
- 文脈考慮
no longer,10.33497428894043 doubt,8.990683555603027 way,8.030651092529297 it is,9.707919120788574 <eos>,9.36506462097168 will,9.056198120117188 was a,8.486732482910156 <unk>,8.168980598449707 the,8.025396347045898 n't a,8.514782905578613 the,8.452549934387207 clear,8.159638404846191 black <eos>,12.180181503295898 in,10.142268180847168 to,9.996685028076172 monday <eos>,11.57198715209961 and,9.793222427368164 in,9.25011920928955 <eos> the,9.041053771972656 but,8.242995262145996 mr.,8.005669593811035
さて今度は、以前までの単語系列を考慮した場合の学習結果です。特筆すべきは n't の次の単語を推測した結果で、文脈を考慮しなかった場合は、<unk>, yet, <eos> が推測結果でしたが、考慮した場合は、a, the, clear と was n't に続きそうな単語が推測されていることが分かります。(yet や <eos> は wasn't の次にはあまりこないように思われます。) なんとなくですが、文脈を考慮して結果が出てきてるような感じですね。モデルやデータセット、学習量によってどれくらい結果が変わるかも調べてみると面白そうです。