けんごのお屋敷

2016-03-14

畳み込みニューラルネットワークによるテキスト分類を TensorFlow で実装する

先日、九工大や東工大などの学生さんが LINE Fukuoka に遊びにきてくれました。せっかく学生さんが遊びに来てくれるので LINE Fukuoka の社員と学生さんとで LT 大会をやろうという運びになって、学生さんは普段やっている研究内容を、LINE Fukuoka 側はなんでも良いので適当な話を、それぞれやりました。当日は私を含む LINE Fukuoka の社員 3 人と、学生さん 2 人の合計 5 人が LT をしました。詳細は LINE Fukuoka 公式ブログに書かれていますので、興味のある方は御覧ください。

[社外活動/報告] 学生を招いてのエンジニア技術交流会を開催しました。

LT に使った資料は公開してもいいよ、とのことだったので、せっかくなので公開。当日はテキスト分類のデモをやったのですが、残念ながらデモ環境までは公開できませんでした。ただ、ソースコードは github で公開していますので見ることができますので後ほどリンクを貼っておきます。

この LT では時間が 15 分しかなくて実装の話は一切できなかったので、せっかくなのでこの記事では実装の話を書こうと思います。実装にあたっては Google 製の機械学習フレームワークの TensorFlow を利用しています。実際は、この実装の元になった実装があって、本当はそれをそのまま使いたかったのですが、自分が適用したいタスクに対してはいくつか問題点があったのと、TensorFlow の感覚を掴みたかったので、自分でスクラッチで実装しました。 以下ソースコードです。

tkengo/tf/cnn_text_classification

また、もし時間があれば、この記事を読む前に以下のリンク先も合わせて読んでおくとより理解が深まると思います。


なお、本記事では実装の詳しい内容を書いていますので、以下のような方を対象読者と想定しています。

  • ニューラルネットワークに対する簡単な基礎知識はある
  • TensorFlow の MNIST For ML Beginners チュートリアルは完了して基本的な概念と使い方の知識はある

完全に初心者だという方は少し難しい内容かもしれませんが、別に当てはまってなければ読んではダメというものでもないので、興味のある方は是非読み進めてみてください。

実装のお話

では、具体的な実装のお話をしていきます。今回はスライドにもあるように、テキストデータをあるカテゴリに自動分類したいというのがモチベーションです。全体の処理の流れとしては以下のようになっています。

  1. 前処理
  2. 畳み込みニューラルネットワークの定義
    • 入力層
    • 埋め込み層
    • 畳み込み層
    • プーリング層
    • 全結合層
    • 出力層
  3. 目的関数の定義
  4. 学習

前処理は、学習用データをニューラルネットワークへ渡せるようにデータを整形してあげる処理です。その整形された入力を元にニューラルネットワークの処理が始まります。ニューラルネットワーク自体は全部で 5 層あって (※入力層はカウントしてません) 最初の層である埋め込み層の後に、一般的な CNN に見られるような畳み込み層、プーリング層、全結合層、が続き、最後に softmax 関数を使った出力層があります。

後ほど詳しく見ていきます。

前処理

学習用データ収集

機械学習のタスクをやる場合にはまず最初に学習用データを準備しないといけません。たぶんここが一番面倒くさい部分じゃないでしょうかね。今回は、自社の悩み相談所含め、Web 上の各種悩み投稿サイトからスクレイピングして約 13 万件のデータを取得しました。取得したデータは、カテゴリ ID とテキスト本文のタブ区切りになるように整形して 1 つのファイルにまとめました。カテゴリ ID はこっちで勝手に決めたもので、たとえば “恋愛” は 1、”結婚” は 2、”仕事” は 3、などです。

1   私は恋がしたいです。
2   私は結婚を真剣に考えています。
3   今の職場が嫌で早く転職をしたいです。

テキストの数値化

さて、それでは前処理のお話に移ります。ソースコードはこのようになっています。

lines = [ l.split("\t") for l in list(open(RAW_FILE).readlines()) ]

contents = [ split_word(tagger, l[1]) for l in lines ]
contents = padding(contents, max([ len(c) for c in contents ]))
labels   = [ one_hot_vec(int(l[0]) - 1) for l in lines ]

ctr = Counter(itertools.chain(*contents))
dictionaries     = [ c[0] for c in ctr.most_common() ]
dictionaries_inv = { c: i for i, c in enumerate(dictionaries) }

data = [ [ dictionaries_inv[word] for word in content ] for content in contents ]

まずは、取得したデータを単語に分割する必要がありますので、形態素解析器を使って分かち書きにします。split_word がそれです。今回は MeCab の Python バインディング を使用していて、辞書には @overlast さんの neologd を使わせてもらいました。この分かち書きの結果を使って、以下の様なテキスト内に出現する全単語の辞書を作ります (※MeCab の辞書とは違うので注意)

1: 私
2: は
3: 恋
4: が
5: し
6: たい
7: です
8: 。
9: 結婚
10: を
11: 真剣
12: に
...

先頭の数字は単なるインデックスであって、単語の出現回数ではないので注意。この辞書のインデックスを使えば、さっきのテキストは以下のように数値型の配列で表すことができます (※本当はストップワードは取り除いたほうが良いと思いますが、今回は面倒だったのでそのままにしています)

[ 1 2 3 4 5 6 7 8 ]
[ 1 2 9 10 11 12 13 14 15 16 8 ]
[ 17 18 19 4 20 21 22 23 10 5 6 7 8]

さらに、ニューラルネットワークへの入力は固定長である必要があるため、学習用データの中で最も長い配列の長さに合わせてパディングします。パディングはテキストの最後に適当な単語 - 今回の実装では <PAD/> - を埋めるようにします。<PAD/> の単語 ID が 0 だとすると、パディング後のデータはこうなります。

[  1  2  3  4  5  6  7  8  0  0 0 0 0 ]
[  1  2  9 10 11 12 13 14 15 16 8 0 0 ]
[ 17 18 19  4 20 21 22 23 10  5 6 7 8 ]

長さが揃いましたね。今回は、このような配列がニューラルネットワークへの入力データとなります。

畳み込みニューラルネットワークの定義

入力層

前処理が完了したらいよいよニューラルネットワークの処理へと移っていきます。最初に入力層です。TensorFlow では placeholder を使います。

x_dim = train_x.shape[1]
input_x = tf.placeholder(tf.int32,   [ None, x_dim       ])
input_y = tf.placeholder(tf.float32, [ None, NUM_CLASSES ])

train_x には、前処理で生成したテキストデータを数値配列化したものがデータの数だけ (つまり 13 万件程度) 配列で入っています。配列の配列ですね。そして x_dim には、テキストデータの次元数が入ります。入力層のニューロンを表すのが input_x ですね。学習はミニバッチ分割法で行われるので、複数のデータを受け取れるように [ None, x_dim ] のテンソルになっています。

埋め込み層

入力層の次は埋め込み層 (Embedding Layer) です。ここでは入力されたテキスト (実際は数値型の配列) の埋め込み表現を学習します。単語の埋め込み表現としては word2vec が有名で、論文内でも word2vec で事前学習した埋め込み表現を使うようになっていますが、今回はそのような外部ツールは使わずにスクラッチで埋め込み表現を学習させます。理由は 2 つあります。

  • word2vec を使って事前学習させるの面倒くさい
  • TensorFlow には embedding_lookup という埋め込み表現を学習するため関数がある

そういうわけで、論文とは違って word2vec は使わずに TensorFlow の embedding_lookup を使った実装となっています。以下が該当箇所です。

w  = tf.Variable(tf.random_uniform([ len(d), EMBEDDING_SIZE ], -1.0, 1.0), name='weight')
e  = tf.nn.embedding_lookup(w, input_x)
ex = tf.expand_dims(e, -1)

この層の重み行列は w で、[ 単語の種類の総数 x 埋め込み表現の次元数 ] というサイズになっています。今回は埋め込み表現の次元数は EMBEDDING_SIZE = 128 に設定しました。この次元数はハイパーパラメータとして実装者側でチューニングする必要がある部分です。

embedding_lookup[ None, x_dim, EMBEDDING_SIZE ] の形のテンソルを返します。テキストデータの各単語について、EMBEDDING_SIZE の次元数を持つ埋め込み表現のベクトルを見つけてくれるわけですね。

最後に expand_dims という関数を呼んでいますが、これは引数に渡されたテンソルの次元を拡張してくれるもので、このソースコードの場合は結果的に [ None, x_dim, EMBEDDING_SIZE, 1 ] というテンソルになります。これは、次の畳み込み層で conv2d という関数を使いますが、この関数が引数として 4 次元のテンソルを取るため、次元を合わせています。

畳み込み層とプーリング層

単語の埋め込み表現を獲得できたら次に CNN のキモである畳み込み層とプーリング層です。

for filter_size in FILTER_SIZES:
    w  = tf.Variable(tf.truncated_normal([ filter_size, EMBEDDING_SIZE, 1, NUM_FILTERS ], stddev=0.02), name='weight')
    b  = tf.Variable(tf.constant(0.1, shape=[ NUM_FILTERS ]), name='bias')
    c0 = tf.nn.conv2d(ex, w, [ 1, 1, 1, 1 ], 'VALID')
    c1 = tf.nn.relu(tf.nn.bias_add(c0, b))
    c2 = tf.nn.max_pool(c1, [ 1, x_dim - filter_size + 1, 1, 1 ], [ 1, 1, 1, 1 ], 'VALID')
    p_array.append(c2)

重み w がフィルタそのもので、[ フィルタの高さ x フィルタの幅 x フィルタのチャンネル数 x フィルタの数 ] というサイズのテンソルです。

今回は高さが 3、4、5 という 3 つサイズを準備して、それぞれのサイズで 128 個のフィルタを使うようにしています。フィルタの幅は、埋め込み表現の次元数と同じにします (つまりテキストを表す行列の列数と同じ)。要するに

  • 3 x EMBEDDING_SIZE というフィルタが 128 個
  • 4 x EMBEDDING_SIZE というフィルタが 128 個
  • 5 x EMBEDDING_SIZE というフィルタが 128 個

という風に、合計して 384 個のフィルタを学習することになります。畳み込みの処理自体は TensorFlow の conv2d という関数を使います。

384 個のフィルタそれぞれで畳み込みをかけた後は、最大プーリングです。TensorFlow では max_pool という関数を使います。ここでは、プーリングはウィンドウをスライドさせて適用するのではなく、1 つのフィルタ結果全体に対して適用します。つまりフィルタは全部で 384 個あるので、結果的に 384 個のニューロンが出力されると考えることができますね。

全結合層と出力層

最後は全結合層です。

w  = tf.Variable(tf.truncated_normal([ total_filters, NUM_CLASSES ], stddev=0.02), name='weight')
b  = tf.Variable(tf.constant(0.1, shape=[ NUM_CLASSES ]), name='bias')
h0 = tf.nn.dropout(tf.reshape(p, [ -1, total_filters ]), keep)
predict_y = tf.nn.softmax(tf.matmul(h0, w) + b)

まず、過学習防止のためにドロップアプトを実行しています。ドロップアウトの確率は、一般的によく使われる 0.5 にしています。そして、全結合層での定石通りに、入力と重み行列の積を取り、バイアスを足すという操作をして、最後にその出力を softmax 関数に通して、各クラスに属する確率を出力しています。つまり predict_y[ None, NUM_CLASSES ] の形をしたテンソルになります。

これが今回実装した畳み込みニューラルネットワークの全体です。

目的関数の定義

畳み込みニューラルネットワークの定義が完了したら次は目的関数を定義します。

xentropy = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(predict_y, input_y))
loss = xentropy + L2_LAMBDA * tf.nn.l2_loss(w)
global_step = tf.Variable(0, name="global_step", trainable=False)
train = tf.train.AdamOptimizer(0.0001).minimize(loss, global_step=global_step)

ニューラルネットワークでは、一般的に目的関数には二乗誤差よりも公差エントロピーが使われます。TensorFlow には softmax_cross_entropy_with_logits という関数がありますので、これを使います。さらに今回は l2_loss 関数を使って、正則化項も追加しています。ラムダの値は L2_LAMBDA = 0.0001 としました。

そして、定義した目的関数を最小化するための最適化器を作ります。TensorFlow では様々な最適化器を持っていますが、ここでは AdamOptimizer を利用しました。TensorFlow は自動微分機能を持っているので、最適化のために定義した目的関数の微分をする必要はありません。楽だ。

学習

最後に、定義した畳み込みニューラルネットワークの学習を行います。

for epoch in xrange(NUM_EPOCHS):
    random_indice = np.random.permutation(train_x_length)
    for i in xrange(batch_count):
        mini_batch_x = []
        mini_batch_y = []
        for j in xrange(min(train_x_length - i * NUM_MINI_BATCH, NUM_MINI_BATCH)):
            mini_batch_x.append(train_x[random_indice[i * NUM_MINI_BATCH + j]])
            mini_batch_y.append(train_y[random_indice[i * NUM_MINI_BATCH + j]])

        _, v1, v2, v3, v4 = sess.run(
            [ train, loss, accuracy, loss_sum, accr_sum ],
            feed_dict={ input_x: mini_batch_x, input_y: mini_batch_y, keep: 0.5 }
        )

機械学習では一般的に学習は 1 回だけではなく繰り返し行いますので、今回も例に漏れず一定回数の繰り返しを行っています。NUM_EPOCHS = 10 回の繰り返しを行います。そして各エポック内で学習データをミニバッチの数だけランダムに取り出して、それを最適化器に渡して学習を進めています。ミニバッチ数は NUM_MINI_BATCH = 64 に設定しました。

学習自体は sess.run で実行しています。ここが一番ホットな処理部分でしょう。目的関数を微分して勾配を求め、Adam のアルゴリズムに沿って目的関数の値を最小化していきます。

まとめ

だいぶ詳細をはしょりながら早足で説明してきました。

ニューラルネットワークでは重みやバイアスなどのパラメータの数が膨大になるのが普通で、今回も正確には数えてませんがたぶん数万個〜数十万個くらいはあるでしょう。スライドにも書いていますが、13 万件程度のデータを 10 エポック繰り返して学習させるには 20 時間程度の時間が必要でした。ただし、今回は CPU しか使っていませんので GPU を使えばもっともっと早く終わるでしょう。

それから、今回実装した畳み込みニューラルネットワークにはたくさんのハイパーパラメータがありました。埋め込み表現の次元数、フィルタ数、フィルタのサイズ、ミニバッチ数、繰り返し (エポック) 数、などなど。これらは、ある程度よく使われている初期値があるとは言え、どういった値にするのかは全部実装者が決めないといけないので、その辺りのチューニングも大事になります。ほとんどのハイパーパラメータについてはトライ&エラーで適切な値を見つけていくしかなく、そうすると 1 回の学習が終わるのに数十時間もかかってるようでは、ハイパーパラメータを修正しながらのチューニングは現実的ではないので、やっぱり GPU が欲しくなります。

と、こんなところでしょうか。

理解できた、できなかった、イミフ、おいしいの?、など感想はいろいろあると思いますが、少しでも誰かの刺激になってれば幸いです。

参考ページ

ニューラルネットワークの入門や解説が書かれている記事や、今回の実装で使われたツールやライブラリへのリンク集です。

[1] と [2] はニューラルネットワークとディープラーニングについてのオンライン本の英語版と日本語版。日本語版の方はまだ英語の部分も残ってるようで完全に日本語化されてない。

[3] と [4] はそれぞれ、畳み込みニューラルネットワークの説明と、自然言語処理にディープラーニングを使う話。スタンフォード大学が公開。英語。

[5] は機械学習初心者に向けた解説。東京大学の有志が公開。日本語。

[6] と [7] は畳み込みニューラルネットワークを自然言語処理に適用する解説記事の英語版と日本語版。

[8] が今回実装したニューラルネットワークの元になった論文。英語。

[9] と [10] はライブラリ。

[11] は今回の実装の全ソースコード。

  • このエントリーをはてなブックマークに追加