INFORMATION
テクノロジ

LuceneとMahoutの文書分類機能比較

本稿は 2014/2/25 に soleami.com に投稿された記事の転載です。

Author: 関口宏司

Lucene は 4.2 から文書分類ツールが提供されるようになりました。そこでこの記事では同じコーパスを Lucene と Mahout で文書分類をして比較してみます。

Lucene には、単純ベイズと k-NN 法による分類器が実装されています。次期メジャーリリースの Lucene 5 に相当する trunk にはそれに加えて2値分類のパーセプトロンが実装されています。この記事では執筆時の最新バージョンである Lucene 4.6.1 を用いて単純ベイズと k-NN 法で文書分類をしてみます。

一方、Mahout は同じく単純ベイズと、さらにランダムフォレストで文書分類を行ってみましょう。

Lucene の文書分類の概要

Lucene の文書分類の分類器は、Classifier インタフェースとして定義されています。

public interface Classifier {

/**
* Assign a class (with score) to the given text String
* @param text a String containing text to be classified
* @return a {@link ClassificationResult} holding assigned class of type <code>T</code> and score
* @throws IOException If there is a low-level I/O error.
*/
public ClassificationResult assignClass(String text) throws IOException;

/**
* Train the classifier using the underlying Lucene index
* @param atomicReader the reader to use to access the Lucene index
* @param textFieldName the name of the field used to compare documents
* @param classFieldName the name of the field containing the class assigned to documents
* @param analyzer the analyzer used to tokenize / filter the unseen text
* @param query the query to filter which documents use for training
* @throws IOException If there is a low-level I/O error.
*/
public void train(AtomicReader atomicReader, String textFieldName, String classFieldName, Analyzer analyzer, Query query)
throws IOException;
}

Classifier はインデックスを学習データとして用います。そのため、あらかじめ用意したインデックスをオープンした IndexReader を用意し、train() メソッドの第1引数に指定します。train() メソッドの第2引数にはトークナイズおよび索引付けされたテキストが入った Lucene フィールド名を指定します。train() メソッドの第3引数には、文書カテゴリが入った Lucene フィールド名を指定します。第4引数には Lucene の Analyzer を、第5引数には Query をそれぞれ渡します。Analyzer は、このあと未知文書を分類する際に使われる Analyzer を指定します(これはちょっとわかりにくいと私は思います。後述の assignClass() メソッドの引数にむしろするべきだと思いますね)。Query は、学習に使われる文書を絞り込むのに使われ、その必要がないときは null を指定します。train() メソッドにはこれ以外に引数を変えた2つのバリエーションがありますが省略します。

Classifier インタフェースの train() を呼び出したら String 型の未知文書を引数にして assignClass() メソッドを呼び、分類結果を取得します。Classifier は Java の Generics を用いたインタフェースになっていますが、その型変数 T を用いた ClassificationResult クラスが assignClass() の戻り値です。

public class ClassificationResult {

private final T assignedClass;
private final double score;

/**
* Constructor
* @param assignedClass the class <code>T</code> assigned by a {@link Classifier}
* @param score the score for the assignedClass as a <code>double</code>
*/
public ClassificationResult(T assignedClass, double score) {
this.assignedClass = assignedClass;
this.score = score;
}

/**
* retrieve the result class
* @return a <code>T</code> representing an assigned class
*/
public T getAssignedClass() {
return assignedClass;
}

/**
* retrieve the result score
* @return a <code>double</code> representing a result score
*/
public double getScore() {
return score;
}
}

ClassificationResult の getAssignedClass() メソッドを呼ぶと、T 型の分類結果を得ることができます。

Lucene の分類器のユニークなところは、なんといっても train() メソッドではほとんど何も仕事をせず、assignClass() で頑張るところでしょう。これは他の一般的な機械学習ソフトウェアと大きく異なる部分です。一般的な機械学習ソフトウェアの学習フェーズでは、選択した機械学習アルゴリズムに沿ってコーパスを学習してモデルファイルを作成します(この部分に多くの時間を割くわけですが、Mahout は Hadoop ベースなのでこの部分を MapReduce で時間短縮することを狙っています)。そして分類フェーズでは先に作成したモデルファイルを参照し、未知文書を分類します。一般的にこのフェーズは少量のリソースしか要しません。

Lucene の場合はインデックスをモデルファイルとして使うので、学習フェーズである train() メソッドではほとんど何もする必要がありません(学習はインデックスを作成することで終わっています)。しかし、Lucene のインデックスはキーワード検索を高速に実行するために最適化されており、文書分類のモデルファイルとして適当な形式ではありません。そこで分類フェーズである assignClass() メソッドでインデックスを検索し、文書分類を行っています。そのため、Lucene の分類器は一般的な機械学習ソフトウェアとは逆に、分類フェーズに大きな計算機パワーを必要とします。しかしながら、検索を主目的に行うサイトではインデックスを作るので、追加投資なしで文書分類ができる Lucene のこの機能は魅力的でしょう。

では次に、Classifier インタフェースの2つの実装クラスがどのように文書分類を行っているか簡単に見ながら、プログラムから実際に呼び出してみましょう。

Lucene の SimpleNaiveBayesClassifier を使う

SimpleNaiveBayesClassifierはClassifier インタフェースの1つめの実装クラスです。名前からわかるとおり、単純ベイズ分類器です。単純ベイズ分類では、ある文書 d のときにクラスが c となる条件付き確率 P(c|d) が最大になる c を求めます。このとき、ベイズの定理を用いて P(c|d) を式変形しますが、確率が最大になるときのクラス c を求めるためには、 P(c)P(d|c) を求めることになります。通常はアンダーフローを防ぐために対数を計算しますが、SimpleNaiveBayesClassifierのassignClass() メソッドはクラス数の分だけひたすらこの計算を行い、最尤推定を行っています。

では早速、SimpleNaiveBayesClassifier を使ってみたいと思いますが、まずは学習データをインデックスに用意しなければなりません。ここではコーパスとして livedoor ニュースコーパスを使うことにします。livedoor ニュースコーパスを次のようなスキーマ定義の Solr を使ってインデックスに登録します。

<?xml version="1.0" encoding="UTF-8" ?>
<schema name="example" version="1.5">
<fields>
<field name="url" type="string" indexed="true" stored="true" required="true" multiValued="false" />
<field name="cat" type="string" indexed="true" stored="true" required="true" multiValued="false"/>
<field name="title" type="text_ja" indexed="true" stored="true" multiValued="false"/>
<field name="body" type="text_ja" indexed="true" stored="true" multiValued="true"/>
<field name="date" type="date" indexed="true" stored="true"/>
</fields>
<uniqueKey>url</uniqueKey>
<types>
<fieldType name="string" class="solr.StrField" sortMissingLast="true" />
<fieldType name="boolean" class="solr.BoolField" sortMissingLast="true"/>
<fieldType name="int" class="solr.TrieIntField" precisionStep="0" positionIncrementGap="0"/>
<fieldType name="float" class="solr.TrieFloatField" precisionStep="0" positionIncrementGap="0"/>
<fieldType name="long" class="solr.TrieLongField" precisionStep="0" positionIncrementGap="0"/>
<fieldType name="double" class="solr.TrieDoubleField" precisionStep="0" positionIncrementGap="0"/>
<fieldType name="date" class="solr.TrieDateField" precisionStep="0" positionIncrementGap="0"/>
<fieldType name="text_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
<analyzer>
<tokenizer class="solr.JapaneseTokenizerFactory" mode="search"/>
<filter class="solr.JapaneseBaseFormFilterFactory"/>
<filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
<filter class="solr.CJKWidthFilterFactory"/>
<filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
<filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
<filter class="solr.LowerCaseFilterFactory"/>
</analyzer>
</fieldType>
</types>
</schema>

ここで cat フィールドが分類クラス、body フィールドが学習対象フィールドです。上記の schema.xml を使って Solr を起動したら、livedoor ニュースコーパスを登録します。登録後は Solr を停止してかまいません。

次に、SimpleNaiveBayesClassifier を使う Java プログラムを用意します。ここでは簡単のために、学習に使う文書をそのまま分類のテストにも使います。プログラムは次のようになります。

public final class TestLuceneIndexClassifier {

public static final String INDEX = "solr2/collection1/data/index";
public static final String[] CATEGORIES = {
"dokujo-tsushin",
"it-life-hack",
"kaden-channel",
"livedoor-homme",
"movie-enter",
"peachy",
"smax",
"sports-watch",
"topic-news"
};
private static int[][] counts;
private static Map<String, Integer> catindex;

public static void main(String[] args) throws Exception {
init();

final long startTime = System.currentTimeMillis();
SimpleNaiveBayesClassifier classifier = new SimpleNaiveBayesClassifier();
IndexReader reader = DirectoryReader.open(dir());
AtomicReader ar = SlowCompositeReaderWrapper.wrap(reader);

classifier.train(ar, "body", "cat", new JapaneseAnalyzer(Version.LUCENE_46));
final int maxdoc = reader.maxDoc();
for(int i = 0; i < maxdoc; i++){
Document doc = ar.document(i);
String correctAnswer = doc.get("cat");
final int cai = idx(correctAnswer);
ClassificationResult result = classifier.assignClass(doc.get("body"));
String classified = result.getAssignedClass().utf8ToString();
final int cli = idx(classified);
counts[cai][cli]++;
}
final long endTime = System.currentTimeMillis();
final int elapse = (int)(endTime - startTime) / 1000;

// print results
int fc = 0, tc = 0;
for(int i = 0; i < CATEGORIES.length; i++){
for(int j = 0; j < CATEGORIES.length; j++){
System.out.printf(" %3d ", counts[i][j]);
if(i == j){
tc += counts[i][j];
}
else{
fc += counts[i][j];
}
}
System.out.println();
}
float accrate = (float)tc / (float)(tc + fc);
float errrate = (float)fc / (float)(tc + fc);
System.out.printf("\n\n*** accuracy rate = %f, error rate = %f; time = %d (sec); %d docs\n", accrate, errrate, elapse, maxdoc);

reader.close();
}

static Directory dir() throws IOException {
return FSDirectory.open(new File(INDEX));
}

static void init(){
counts = new int[CATEGORIES.length][CATEGORIES.length];
catindex = new HashMap<String, Integer>();
for(int i = 0; i < CATEGORIES.length; i++){
catindex.put(CATEGORIES[i], i);
}
}

static int idx(String cat){
return catindex.get(cat);
}
}

Analyzer には JapaneseAnalyzer を指定しています(一方、インデックス作成時は Solr の機能を用いて JapaneseTokenizer と関連する TokenFilter を使っており、若干の違いがあります)。文字列配列 CATEGORIES には、文書カテゴリがハードコーディングしてあります。このプログラムを実行すると、Mahout のような confusion matrix を表示しますが、matrix の要素はこのハードコーディングされた文書カテゴリの配列要素の順番です。

このプログラムを実行すると、次のようになります。

760 0 4 23 37 37 2 2 5
40 656 7 44 25 4 90 1 3
87 57 392 102 68 24 113 5 16
40 15 6 391 33 8 16 2 0
14 2 0 5 845 2 0 1 1
134 2 2 26 107 549 19 3 0
43 36 13 17 26 36 693 5 1
6 0 0 23 35 0 1 829 6
10 9 9 25 66 6 5 45 595

*** accuracy rate = 0.775078, error rate = 0.224922; time = 67 (sec); 7367 docs

分類正解率が77%となりました。

Lucene の KNearestNeighborClassifier を使う

Classifier のもうひとつの実装クラスが KNearestNeighborClassifier です。KNearestNeighborClassifier は、コンストラクタの引数に1以上のkを指定してインスタンスを作成します。プログラムは SimpleNaiveBayesClassifier のプログラムとまったく同じものが使えます。SimpleNaiveBayesClassifier のインスタンスを作っている部分を KNearestNeighborClassifier に置き換えるだけです。

KNearestNeighborClassifierもassignClass() メソッドが頑張るのは前述と同じですが、面白いのは Lucene の MoreLikeThis を使っている点です。MoreLikeThis は基準となる文書をクエリとみなして検索を実行するツールです。これにより、基準となる文書と類似した文書を探すことができます。KNearestNeighborClassifier では MoreLikeThis を使って assignClass() メソッドに渡された未知文書と類似した文書の上位 k 個を取得します。そしてk個の文書が所属する文書カテゴリの多数決で未知文書の文書カテゴリを決定します。

KNearestNeighborClassifier を使った同じプログラムを実行すると、k=1 の場合、次のようになりました。

724 14 28 22 6 30 8 18 20
121 630 41 13 2 9 35 6 13
165 28 582 10 5 16 26 7 25
229 15 15 213 6 14 6 2 11
134 37 15 8 603 12 19 7 35
266 38 39 24 14 412 22 9 18
810 16 1 3 2 3 32 1 2
316 18 14 12 5 7 8 439 81
362 17 29 10 1 7 7 16 321

*** accuracy rate = 0.536989, error rate = 0.463011; time = 13 (sec); 7367 docs

正解率は53%です。さらに k=3 でやってみると、正解率はさらに下がって48%となりました。

652 5 78 3 7 40 13 38 34
127 540 82 15 1 10 58 23 14
169 34 553 3 7 16 38 15 29
242 10 32 156 12 13 15 10 21
136 30 21 9 592 11 19 15 37
309 34 58 5 23 318 40 28 27
810 8 3 1 0 10 37 1 0
312 8 44 7 5 2 13 442 67
362 11 45 5 6 10 16 34 281

*** accuracy rate = 0.484729, error rate = 0.515271; time = 9 (sec); 7367 docs

NLP4L と Mahout の文書分類

Mahout で Lucene のインデックスを入力データとして扱う場合、便利なコマンドが用意されています。しかし、教師あり学習の文書分類の目的で使う場合は、クラスを示すフィールド情報を文書ベクトルとともに出力する必要があります。

これを簡単に行えるのが弊社開発の NLP4L の MSDDumper や TermsDumper です。NLP4L は Natural Language Processing for Lucene の略であり、Lucene のインデックスをコーパスとみなした自然言語処理ツールセットです。

MSDDumper や TermsDumper は設定によって、指定した Lucene のフィールドから tf*idf などに基づいた重要語を選択・抽出して Mahout コマンドで読み取りやすい形式で出力してくれます。この機能を利用して、インデックスの body フィールドから重要語を2,000語選び、それで Mahout の分類を実行してみます。

結果だけ示すと、Mahout の単純ベイズでは正解率が96%となりました。

=======================================================
Summary
-------------------------------------------------------
Correctly Classified Instances : 7128 96.7689%
Incorrectly Classified Instances : 238 3.2311%
Total Classified Instances : 7366

=======================================================
Confusion Matrix
-------------------------------------------------------
a b c d e f g h i <--Classified as
823 1 1 6 12 19 2 4 2 | 870 a = dokujo-tsushin
1 848 2 1 0 1 11 4 2 | 870 b = it-life-hack
5 6 830 1 1 0 3 1 17 | 864 c = kaden-channel
2 6 6 486 3 1 6 0 0 | 510 d = livedoor-homme
0 0 1 1 865 1 0 1 1 | 870 e = movie-enter
31 3 6 12 14 762 6 4 4 | 842 f = peachy
0 0 2 0 0 1 867 0 0 | 870 g = smax
0 0 0 1 0 0 0 897 2 | 900 h = sports-watch
2 4 1 1 0 0 0 12 750 | 770 i = topic-news

=======================================================
Statistics
-------------------------------------------------------
Kappa 0.955
Accuracy 96.7689%
Reliability 87.0076%
Reliability (standard deviation) 0.307

また、Mahout のランダムフォレストでは正解率が97%となりました。

=======================================================
Summary
-------------------------------------------------------
Correctly Classified Instances : 7156 97.1359%
Incorrectly Classified Instances : 211 2.8641%
Total Classified Instances : 7367

=======================================================
Confusion Matrix
-------------------------------------------------------
a b c d e f g h i <--Classified as
838 5 2 6 3 7 2 0 1 | 864 a = kaden-channel
0 895 0 1 4 0 0 0 0 | 900 b = sports-watch
0 0 869 0 0 1 0 0 0 | 870 c = smax
0 2 0 839 1 0 14 2 12 | 870 d = dokujo-tsushin
1 17 0 0 748 0 2 0 2 | 770 e = topic-news
1 5 0 1 5 855 2 0 1 | 870 f = it-life-hack
0 1 0 23 0 0 793 1 24 | 842 g = peachy
0 11 0 14 1 2 18 454 11 | 511 h = livedoor-homme
0 1 0 2 0 0 2 0 865 | 870 i = movie-enter

=======================================================
Statistics
-------------------------------------------------------
Kappa 0.9608
Accuracy 97.1359%
Reliability 87.0627%
Reliability (standard deviation) 0.3076

まとめ

この記事では同じコーパスを使って、Lucene と Mahout の文書分類を実行して比較してみました。正解率は Mahout の方が高く見えますが、既に述べた通り、Mahout の学習データは分類のためにすべての単語を使わず、body フィールドの重要語上位2,000語を用いています。一方、正解率が70%にとどまった Lucene の分類器の方は、body フィールドのすべての単語を使っています。文書分類用に精査した単語のみを保持するフィールドを設ければ、Lucene でも90%を超える正解率を出せるでしょう。train() メソッドにそのような機能を持つ別の Classifier 実装クラスを作る、というのもいいかもしれません。

なお、テストデータを学習に用いないで真の未知データとしてテストすると、80%超程度まで正解率は落ちることを付け加えておきます。

本記事が Lucene と Mahout ユーザの皆様のお役に立てれば幸いです。


KandaSearch

KandaSearch はクラウド型企業向け検索エンジンサービスです。
オープンAPIでカスタマイズが自由にできます。

  • セマンティックサーチ

    人間が理解するように検索エンジンがテキストや画像を理解して検索できます。

  • クローラー

    検索対象文書を収集するWebクローラーが使えます。

  • 簡単操作のUIと豊富なライブラリー

    検索や辞書UIに加え、定義済み専門用語辞書/類義語辞書やプラグインがあります。

  • ローコードで低コスト導入

    検索UIで使い勝手を調整した後、Webアプリケーションを自動生成できます。

セミナー

企業が検索エンジンを選定する際のポイントから、
実際の導入デモをお客様ご自身でご体験!