Mahoutで体感する機械学習の実践

第9回(最終回) ナイーブベイズを用いてテキスト分類を行う

テキスト分類の流れ

前回は理論編として、自由回答式アンケートの分析手法の1つである、アフターコーディングの自動化方法について解説しました。今回は実践編として、Mahoutのナイーブベイズ実装を用いたテキスト分類の手順を見ていきましょう。

今回も、記載しているコマンドおよびサンプルコードはMahoutのバージョン0.7とバージョン0.8の双方で動作します。

今回取り上げるMahoutのナイーブベイズ実装を用いたテキスト分類のおもな流れは、以下のようになります。

  1. 形態素解析器を用いて、学習データの各文章を分かち書く
  2. 分かち書きされた学習データをシーケンスファイルへ変換する
  3. シーケンスファイルをベクトルファイルへ変換する
  4. ベクトルファイルの学習データを用いてモデルを作成する
  5. 生成したモデルの精度をテストする
  6. モデルを用いてテキスト分類を行う

サンプルデータを準備する

今回の学習データには、以下のサンプルデータを利用します。

サンプルデータは、ファミリーレストランのアンケートをシミュレートした65件の回答になっています[1]⁠。

今回のサンプルデータは、1つの回答が1つのテキストファイルで構成されています。

ディレクトリ構成は以下のようになっており、回答が分類されるカテゴリ名がディレクトリ名となっています。

  • learning-data/food-negative ⇒ 味に関する否定的な回答
  • learning-data/food-positive ⇒ 味に関する肯定的な回答
  • learning-data/price-negative ⇒ 価格に関する否定的な回答
  • learning-data/price-positive ⇒ 価格に関する肯定的な回答
  • learning-data/service-negatie ⇒ サービスに関する否定的な回答
  • learning-data/service-positive ⇒ サービスに関する肯定的な回答
  • learning-data/else ⇒ 上記いずれにも該当しないその他の回答

今回のサンプルデータは学習データとして利用するため、正しいカテゴリのディレクトリ以下に各回答がすでに配置されています。

テキストの分かち書きを行う

前回解説したように、今回はモデルを構成する特徴として単語を利用します。

しかし、日本語の文章は英語のように各単語がスペースで区切られていないため、このままでは単語を利用することが困難です。そのため、まず形態素解析器を用いて、以下のように文章を単語に分かち書く必要があります。

分かち書き前
ハンバーグが中まで火が通ってなかった。
分かち書き後
ハンバーグ が 中 まで 火 が 通っ て なかっ た 。

日本語の分かち書きの機能はMahoutには用意されていないため、今回のサンプルデータには、すでに以下のディレクトリ以下に分かち書きが済んでいるテキストファイルを用意しています。

wakati-learning-data/food-negative
⇒味に関する否定的な回答の分かち書き済みテキスト
wakati-learning-data/food-positive
⇒味に関する肯定的な回答の分かち書き済みテキスト
⁠以下、省略)

なお、今回のサンプルデータの分かち書きには形態素解析器にmecab辞書にはIPA辞書を利用しています。mecabは現時点(2013年10月29日)の最新バージョンである、0.996を利用しています。

テキストファイルからシーケンスファイルを生成する

Mahoutのナイーブベイズ実装で利用する学習データのファイルフォーマットはテキストファイルに対応しておらず、ベクトルファイルである必要があります。このベクトルファイルは、まずテキストファイルからシーケンスファイルを作成し、次にこのシーケンスファイルから作成します。

そのため、まずは分かち書かれたテキストファイルからシーケンスファイルを作成します。これには、以下のようにMahoutのseqdirectoryコマンドを利用します。

mahout seqdirectory -i file:///home/yamakatu/gihyo-mahout-nb-sample/wakati-learning -o gihyo-mahout-nb-sample/seq-learning

seqdirectoryコマンドには、おもなオプションとして以下があります。

  • --input (-i) ⇒ 入力データ
  • --output (-o) ⇒ シーケンスファイルの出力先
  • --chunkSize (-chunk) ⇒ ファイルの分割サイズ(デフォルトは64Mbytes)
  • --keyPrefix (-prefix) ⇒ 指定した文字列がKeyのprefixに利用される
  • --charset (-c) ⇒ 文字コードを指定(デフォルトはUTF-8)

生成されたシーケンスファイルの内容は、本連載の第5回目でも利用したseqdumperコマンドで確認できます。

mahout seqdumper -i gihyo-mahout-nb-sample/seq-learning

無事、以下のような内容が出力されたでしょうか?

(略)

Key: /price-positive/65.txt: Value: ここ の コーヒー は 、 注文 が 入っ て から エスプレッソマシーン で 入れ て くれ て いる ので 、 とても コス パ が いい と 思う 。

Key: /price-positive/62.txt: Value: ランチ セット は お 得 で 良い と 思う 。

(略)

出力された内容からわかるように、seqdirectoryコマンドはファイルのパスをKeyに、テキストファイルの内容をValueとして、シーケンスファイルを作成します。

シーケンスファイルからベクトルファイルを作成する

次に、作成したシーケンスファイルをベクトルファイルへ変換します。ベクトルファイルへの変換には、以下のようにMahoutのseq2sparseコマンドを利用します。

mahout seq2sparse -i gihyo-mahout-nb-sample/seq-learning -o gihyo-mahout-nb-sample/sparse-learning -a org.apache.lucene.analysis.WhitespaceAnalyzer

今回は最初に分かち書きを済ませているため、各単語間はスペースで区切られています。そのため、アナライザとしてWhitespaceAnalyzerを利用します。

成功すると、以下のように、出力先にtf-vectorsとtfidf-vectorsが生成されます。ベクトル以外にも、df値、辞書ファイル、単語の出現回数などが生成されます。

[yamakatu@localhost data]$ hadoop fs -ls gihyo-mahout-nb-sample/sparse-learning
Found 7 items
drwxr-xr-x   - yamakatu supergroup          0 2013-10-03 10:35 /user/yamakatu/gihyo-mahout-nb-sample/sparse-learning/df-count
-rw-r--r--   1 yamakatu supergroup       1860 2013-10-03 10:34 /user/yamakatu/gihyo-mahout-nb-sample/sparse-learning/dictionary.file-0
-rw-r--r--   1 yamakatu supergroup       1993 2013-10-03 10:35 /user/yamakatu/gihyo-mahout-nb-sample/sparse-learning/frequency.file-0
drwxr-xr-x   - yamakatu supergroup          0 2013-10-03 10:35 /user/yamakatu/gihyo-mahout-nb-sample/sparse-learning/tf-vectors
drwxr-xr-x   - yamakatu supergroup          0 2013-10-03 10:36 /user/yamakatu/gihyo-mahout-nb-sample/sparse-learning/tfidf-vectors
drwxr-xr-x   - yamakatu supergroup          0 2013-10-03 10:34 /user/yamakatu/gihyo-mahout-nb-sample/sparse-learning/tokenized-documents
drwxr-xr-x   - yamakatu supergroup          0 2013-10-03 10:34 /user/yamakatu/gihyo-mahout-nb-sample/sparse-learning/wordcount

tf-vectorsとtfidf-vectorsの内容は、本連載の第5回目でも利用したvectordumpコマンドで以下のように確認することができます。

mahout vectordump -i gihyo-mahout-nb-sample/sparse-learning/tfidf-vectors

tf-vectorsとtfidf-vectors以外のファイルは、これまでと同様にseqdumperコマンドで内容を確認することができます。

ベクトルファイルからモデルを生成する

ベクトルファイルの生成が完了すれば、学習データの準備は完了です。このベクトルファイルを利用して、モデルを生成しましょう。入力データに利用するベクトルファイルはtf値とtf-idf値のうち、今回はtf-idf値を利用してみます。

モデルの生成には、Mahoutのtrainnbコマンドを利用します。

mahout trainnb -i gihyo-mahout-nb-sample/sparse-learning/tfidf-vectors -o gihyo-mahout-nb-sample/model -el -li gihyo-mahout-nb-sample/labelindex

trainnbコマンドのおもなオプションとして、以下があります。

  • --input (-i) ⇒ 学習データ
  • --output (-o) ⇒ モデルの出力先
  • --extractLabels (-el) ⇒ ラベルインデックスを作成
  • --trainComplementary (-c) ⇒ complement naive bayesを利用
  • --labelIndex (-li) ⇒ ラベルインデックスのパスを指定

-elオプションにより作成したラベルインデックスの内容は、これまで同様にseqdumperコマンドで閲覧できます。

mahout seqdumper -i gihyo-mahout-nb-sample/labelindex

以下のように、ラベルインデックスにはクラス名(カテゴリ名)とIDの紐付けが格納されていることが確認できます。

Key: else: Value: 0
Key: food-negative: Value: 1
Key: food-positive: Value: 2
Key: price-nagative: Value: 3
Key: price-positive: Value: 4
Key: service-negative: Value: 5
Key: service-positive: Value: 6

このラベルインデックスは、プログラム内部でクラスのIDからクラス名(カテゴリ名)を参照する際に利用します。

モデルの精度をテストする

モデルを生成した後には、モデルの精度をテストする必要があります。これにはMahoutのtestnbコマンドを利用します。

今回はサンプルのため、学習データをそのままテストデータとしていますが、実際には学習データとは別のデータでテストする必要があります。

mahout testnb -i gihyo-mahout-nb-sample/sparse-learning/tfidf-vectors -o gihyo-mahout-nb-sample/test -m gihyo-mahout-nb-sample/model -l gihyo-mahout-nb-sample/labelindex

testnbのおもなオプションには、以下があります。

  • --input (-i) ⇒ テスト対象のデータ
  • --output (-o) ⇒ テスト結果の出力先
  • --model (-m) ⇒ 利用するモデル
  • --testComplementary (-c) ⇒ complement naive bayesを利用
  • --labelIndex (-li) ⇒ ラベルインデックスのパスを指定

以下のような結果が出力されましたでしょうか。

=======================================================
Summary
-------------------------------------------------------
Correctly Classified Instances          :         57	   87.6923%
Incorrectly Classified Instances        :          8	   12.3077%
Total Classified Instances              :         65

=======================================================
Confusion Matrix
-------------------------------------------------------
a       b       c       d       e       f       g       <--Classified as
10      0       0       0       0       1       0        |  11       a     = else
0       4       1       0       0       0       0        |  5        b     = food-negative
0       0       4       0       0       0       0        |  4        c     = food-positive
0       0       1       5       0       0       1        |  7        d     = price-nagative
0       0       0       0       6       0       0        |  6        e     = price-positive
0       3       1       0       0       17      0        |  21       f     = service-negative
0       0       0       0       0       0       11       |  11       g     = service-positive

実行結果の見方は、本連載の7回目とまったく同じですので、そちらをご参考ください。

モデルを用いて分類する

これまでの手順で、モデルを作成することができました。最後に、このモデルを用いて分類を行います。

これまでの手順はMahoutが用意しているコマンドで実現できましたが、分類するためのコマンドは今のところMahoutには用意されていません。そのため、ライブラリを利用して実装する必要があります。

以降、サンプルコードの該当箇所を例として、実装する際に重要となるポイントを解説します。

モデルを読み込む

ます、以下のようなコードで作成したモデルを読み込みます。

model = NaiveBayesModel.materialize(new Path(modelFilePath), conf);
(省略)
StandardNaiveBayesClassifier classifier = new StandardNaiveBayesClassifier(model);

complement naive bayesを用いる場合は、StandardNaiveBayesClassifierクラスの代わりに、ComplementaryNaiveBayesClassifierクラスを利用します。

Vectorの次元数を決める

次にVectorの次元数を決める必要があります。今回の場合は、すべてのドキュメントを通して出現する単語の数が次元数になります。

単語の数は、seq2sparseを実行して生成された、dictionary.fileやfrequency.file、wordcountから取得できます。しかし、後ほど単語と単語IDの紐付きが必要になるため、ここではその情報が取得できるdictionary.fileを利用して、単語と単語IDの紐付けと単語数を同時に取得してみます。

dicIterator = new SequenceFileIterator<Writable, Writable>(new Path(dictionaryFilePath), true, conf);
(省略)
HashMap<String, Integer> wordIDMap = new HashMap<String, Integer>();
while (dicIterator.hasNext()) {
    Pair<?, ?> record = dicIterator.next();
    String word = record.getFirst().toString();
    Integer wordID = Integer.valueOf(record.getSecond().toString());
    wordIDMap.put(word, wordID);
}

//出現する単語数をベクトルの次元数とする
int wordNum = wordIDMap.size();

これで次元数が求まりましたので、この次元数を持つベクトルを生成します。

//ベクトル生成
Vector vector = new RandomAccessSparseVector(wordNum);

各単語の出現回数をベクトルの各次元の重みとする

次に分類対象のドキュメントにおける各単語の出現回数を、ベクトルの各次元の重みとします[2]⁠。

ここで、先ほど取得した単語とIDの紐付けを利用します。

for(String word : targetDocWordList){ //targetDocWordListは分類対象に含まれているすべての単語を格納
    if(wordIDMap.get(word) == null){
        continue;
    }
    int wordID = wordIDMap.get(word);
    vector.setQuick(wordID, vector.get(wordID) + 1);
}

ここまでで、分類の準備は完了しました。

分類する

最後に、分類を行います。

//分類実行
Vector result = classifier.classifyFull(vector);

分類結果には、そのドキュメントが各クラス(カテゴリ)に属する確率が格納されます。

下記の例では、クラスの数だけループし、各クラスに属する確率を出力しています。格納順序は、モデル作成時に生成されたラベルインデックスのValueの値に対応します。

for (int i = 0; i < result.size(); i++){
    System.out.println("label id:"+i+" probability:"+result.get(i));
}

単純に考えれば、ここで最も確率が高いクラス(カテゴリ)がそのアイテム(文章)が割り振られるべきクラスとなります。

しかし実際には、以下のような場合など、単純に最も確率が高いクラスを採用することが適切でない場合もありますので注意が必要です。

  • 最も確率が高いクラスと2番目に確率が高いクラスとの確率の差が僅差な場合
  • 最も確率が高いクラスの確率がそれほど高くない場合

以上のような手順で、Mahoutのナイーブベイズ実装によるドキュメント分類を行うことができます。

最後に

今回で本連載は終了となります。これまで、さまざまな機械学習の利用シーンを通して、Mahoutが実装している各アルゴリズムや機能を、ほんの一部ではありますがご紹介してきました。

まだMahoutは現時点での最新バージョンが0.8と、1系のリリースには至っておらず、機能面の不足や細かい不具合が散見されます。しかしながら、HDFSによって大量のデータを取り扱うことができ、MapReduceにより処理を高速化できる点において、Mahoutは機械学習のライブラリとして非常に魅力的であり、今後のさらなる開発に大きな期待がかかります。ぜひ、今後のMahoutにご注目ください。

最後になりましたが、これまで本連載を読んでくださった皆様、連載の機会をくださった技術評論社の皆様、この連載を応援してくださった同僚、元同僚の皆様、そして陰ながら支え続けてくれた家族に深く感謝いたします。

おすすめ記事

記事・ニュース一覧