備忘録 blog

Docker/Machine Learning/Linux

KerasのサンプルでMLPを使って文の分類を試してみる

tl;dr

Kerasに付属しているサンプルを使って、MLPで簡単な文書解析を試してみた。

Kerasを試してみる

前回、紹介記事を書いたのでそちらを参照していただけると幸いです。

sharply.hatenablog.com

MLPで文を分類してみる

単純なパーセプトロンニューラルネットワークの中でも初歩的なもので、入力層と出力層が全結合しているものです。これは入力をx,出力をyとすると、Wを重み行列として

{ \displaystyle
y=W^{T}x
}

といった形の単純な数式で表現できる、n次元それぞれの成分と重みの積の和で表されるようなものですが、これでは表現力が乏しく、非線形な問題について解くことが出来ません。そこで多層パーセプトロン(MLP)では、入力層と出力層の間に、それぞれと全結合するような隠れ層が存在し、それが何レイヤーにも積み重なるようになっていることで、それぞれの重みの組み合わせによってモデルがより高い表現力を持つことができると考えられます。

さてKerasのexamplesフォルダにあるreuters_mlp.pyをみてみると、MLPによってロイターの記事をトピックに分類する実装が非常に簡単にできているのがわかります。モデルを見てみると、

model = Sequential()
model.add(Dense(512, input_shape=(max_words,)))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(nb_classes))
model.add(Activation('softmax'))

最初のDense(512)が上の説明での隠れ層に対応し、後のDense(nb_classes)が出力の種類に対応する層=出力層です。それぞれの間にActivation層とDropout層が入っています。Activation層では活性化関数を噛ませることで、前のレイヤーの各成分に重みを掛けた値を足し合わせるだけではなく、それに対して効果を加えることで計算を簡略化したり、性能を上げたりすることができます。

活性化関数としてよく用いられるのがReLUと呼ばれるmax(0,x)で表される関数で、元の値がx<0の場合は常に0になるようなものです。こうやって敢えてデータを捨てることで、より値の特徴を強めたり、また行列を疎にすることで計算を早くすることができます。

その次のDropout層は確率的にノードからのデータを捨てることで、データに対するモデルの頑健性を高めている部分です。0.5ということは50%のデータはランダムに捨てられますが、逆に言うと残りの50%で正しく分類できるように誤差を逆伝播させて学習していると考えることができます。

従って、これだけ短いモデルであっても十分にディープラーニングで必要な要素が盛り込まれていることがわかります。ここではそれを用いて、任意の文データを分類できるように改造してみたいと思います。

(X_train, y_train), (X_test, y_test) = reuters.load_data(nb_words=max_words, test_split=0.2)

の行を、以下に置き換えます。

print('Loading data...')

m = map(int, raw_input().split())

max_words = m[1]

X_train = []
y_train = []

for i in range(m[0]):
  l = map(int, raw_input().split())

  X_train.append(l[1:])
  y_train.append(l[0])

そして次に、文データをこの分類器が読み込めるようなフォーマットに変換しなければなりません。文データはこのような形のtsvで保存されていることを想定しています。

1577 2334
0 1115 422 58 23 151 58 5 179 257 497 0 11 190 23 31 191 1116 99 19 38 20 208 86 91 27 145 80 18 23 745 0 36 498 58 0 48 42 423 21 10
0 36 17 92 17 58 499 367 13 29 68
0 36 36 124 19 1117 0 1118 3 102 171 1 293 368 5 746 4 5 40 29 21 87 12 247 16
0 18 32 59 53 24 369 258
0 179 35 27 1119 1120 8 24
0 36 27 370 600 17 747 21 8
0 500 500 36 320 5 16

文じゃなくて数字じゃないか!と思われるかもしれませんが、ディープラーニングの内部では巨大な行列計算が行われており、入力は数字が成分となっているベクトルが想定されます。だから画像を入力としたい時、従来までの統計的な機械学習手法であれば特徴量抽出をし、その特徴量をベクトルとしてSVMなどにかけていましたし、ディープラーニングで特徴量抽出が要らなくなる!といっても、実際にはn☓mサイズの画像をRGBの3次元で0-255の255段階にデジタル化された値が入力となっているのです。従って1つの画像はn☓m☓3個の要素に分解でき、そしてそれぞれは0-255の間の整数値を取ると言えるのです。

そこで、日本語の文であってもそれを何らかの数字に置換する必要がありますが、方針としては1文字を1つの数字とする、1単語を1つの数字とするの2つがあります。ここでは1単語を1数字にしてみましょう。その時、日本語を形態素解析して単語に分解する必要があります。ライブラリとしてはMeCabなどが有名で、それを使って文を単語に分割し、その単語に対してそれぞれ数字を割り当てることで、文を単語ベクトル(含まれている単語に割り当てられた数字を並べた配列)で表すことができるのです。

例: 「すもももももももものうち」 => すもも/も/もも/も/もも/の/うち => [1, 2, 3, 2, 3, 4 ,5] = uniq => [1, 2, 3, 4, 5]

ここで先のtsvのフォーマットを確認します。

1行目    : <文の総数> <単語の種類数>
2行目以降: <分類されるクラス> [単語ベクトル]

ここでは、このようなフォーマットを選択しました。分類されるクラスというのは、例えばある記述がAに関するものか、Bに関するものかというような分類を施したいときに、Aのとき0、Bのとき1というように数字を割り振ります。nb_classesという変数がソースコード中にありますが、その分類されるクラスの種類数をその変数に代入しておきましょう。

また、ここでは単語ベクトルは可変長にみえますが、MLPでは可変長入力を学習することはできません。ではどうなっているかというと、学習データに含まれる単語の種類数(ここではmax_wordsとします)が決まっていることを利用して、max_words次元のベクトルとして0-1のバイナリエンコーディングしているのです。

max_words = 5のとき
[0, 3, 4] => [1, 0, 0, 1, 1]  
[1, 2, 3] => [0, 1, 1, 1, 0]
[0]       => [1, 0, 0, 0, 0]

こうすれば全ての文が固定長のベクトルになることが言えます。これが、ソースコードの以下の部分です。

tokenizer = Tokenizer(nb_words=max_words)
X_train = tokenizer.sequences_to_matrix(X_train, mode='binary')
X_test = tokenizer.sequences_to_matrix(X_test, mode='binary')

tsvファイルを生成するコードは、pythonで書こうと思ったのですがpython2のunicodeとstr型の闇に飲み込まれてしまったので、Rubyで書きました。日本語処理で手間取ることなく、柔軟に配列やハッシュを操作できるので、こういったちょっとしたコードを書く時はRubyを愛用しています。さっとコードなので汚いですし、私が使うために作ったものなので実際のユースケースに合わせて改変してください。

require 'natto'

nm = Natto::MeCab.new

wordhash = Hash.new{|h,k|h[k]=0}
data = {}

Dir::glob("./*.txt").each do |filename|
  open(filename) do |file|
    sentences = []
    file.each_line.with_index do |line, i|
      word = []
      line = line.chomp
      nm.parse(line) do |w|
        word << w.surface
      end
      word.pop
      word.each{|t| wordhash[t]+=1}
      sentences << word
    end
    data[filename] = sentences
  end
end

word2index = {}

wordhash.to_a.sort!{|a,b| b[1]<=>a[1]}.each_with_index do |word,i|
  word2index[word[0]] = i
end

print "#{[data.map{|t| t[1].size}.inject(:+), word2index.keys.size].join(" ")}\n" 

data.to_a.each_with_index do |file, i|
  file[1].each do |row|
    print "#{[i, row.map{|t| word2index[t]}].join(" ")}\n"
  end
end

また実際に使う際には、句読点などは省いた方がよいので、もとの文じたいがデータとして適しているかについても検討する必要があります。

さて、準備が整いました。標準入力に上記で定めたフォーマットのテキストファイルを流し込むことで実行できます。

$ python reuters_mlp.py < input_data.txt

こうすることで、独自データ用のモデルを作ることができます。予測したいときは、このモデルに対して別の文ベクトルを用意し、predictすることで、どのクラスに分類されるか、またその確率について計算することができます。

今回は1単語に対して1つのスカラー値を割り振るという方法をとりましたが、このモデルでは、もともとのモデル構築時に含まれなかった単語に対して、数字が割り当てられないので何も予想することができません。しばしばこういった問題はゼロ頻度問題と呼ばれ、データがないものにたいしては予測できないという至極当然の問題に、自然言語処理では特に頻繁に出くわすことになるのです。これに対して今回のモデルで対処する方法はないので、できるだけ学習データを増やすか、そうでなければ別のやり方を考えるしかないと思います。これについては、以前の記事でWord2Vecを用いる方法について少し検討を加えました(まだうまくいっていません)。

sharply.hatenablog.com