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

25/02/2014

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<T> {

  /**
   * 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<T> 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<T> {

  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<BytesRef> 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ユーザの皆様のお役に立てれば幸いです。

» Pagetop