概要

Luceneは3.6から形態素解析機能も入って日本語文書が手軽に扱えるようになった。

Hadoopを使う際にこれらの機能を利用すれば何かと便利なんではなかろうかと思ったので、サンプルコードを書いてみた。

@Date 2012/04/26
@Versions CDH3u3, Lucene3.6

英語文書を扱ってみる

英語文書を単語に分割してカウントする処理を書いてみる。

下記はStandardTokenizerでsplitし、小文字で統一するLowerCaseFilter、「a, and, is, to」などの頻出文字列を取り除くStopFilter、単語末尾の「's」を取り除くEnglishPossessiveFilter、複数形などの揺れを統一するKStemFilterなどをかけてWordCountを行っている。

public class EnWordCountMapper extends Mapper<LongWritable, Text, Text, LongWritable> {

    Text text = new Text();
    LongWritable one = new LongWritable(1L);

    public void map(LongWritable key, Text value, Context context)
            throws IOException, InterruptedException {

        // TextをReaderに変換
        Reader reader = new StringReader(value.toString());
            
        // StandardAnalyzerで解析
        TokenStream stream = new StandardTokenizer(Version.LUCENE_36, reader);
            
        // ASCIIFoldingFilterでExtended Latinな文字や全角文字をLatin1な文字に変換
        stream = new ASCIIFoldingFilter(stream);
            
        // LowerCaseFilterで小文字に統一
        stream = new LowerCaseFilter(Version.LUCENE_36, stream);

        // StopFilterでa, and, is, toなどを除外
        stream = new StopFilter(Version.LUCENE_36, stream, StandardAnalyzer.STOP_WORDS_SET);

        // LengthFilterで2文字〜20文字の文字列以外は除外(あまり長い文字列はいらないよね)
        stream = new LengthFilter(false, stream, 2, 20);

        // EnglishPossessiveFilterで「's」を除去
        stream = new EnglishPossessiveFilter(Version.LUCENE_36, stream);

        // KStemFilterを使って複数形とかの揺れを吸収
        stream = new KStemFilter(stream);

        // 結果を出力
        while (stream.incrementToken()) {
            CharTermAttribute term = stream.getAttribute(CharTermAttribute.class);
            text.set(term.toString());
            context.write(text, one);
        }
    }
}

コード全文はこちら

これらを指定すると例えば下記のように文字列が変換される。

"What's the matter?" demanded Mr. Button appalled. "Triplets?"

  ↓

what	matter	demand	mr	button	appall	triplet

ダブルコーテーションやピリオドなどの記号は除外され、What'sはwhatに、appalledのような過去形やTripletsのような複数形は基本形に変換されます(KStemmerが頑張ってくれる範囲で)。

ASCIIFoldingFilterを使ってるけど、Solrのexampleにあるmapping-FoldToASCII.txtを利用してCharFilterを走らせた方が良いような気もする。

日本語文書を扱ってみる

次に日本語。

下記コードは、JapaneseAnalyzerで形態素解析し、ICUNormalizer2FilterでUnicode正規化、LowerCaseFilterで小文字統一、StopFilterで「こと」「これ」「できる」などの頻出語を除外、JapaneseBaseFormFilterで動詞の活用形を揃え、JapanesePartOfSpeechStopFilterで助詞や接続詞を除外、JapaneseKatakanaStemFilterでカタカナの長音のあるなしを統一しています。

public class JaWordCountMapper extends Mapper<LongWritable, Text, Text, LongWritable> {

    Text text = new Text();
    LongWritable one = new LongWritable(1L);

    public void map(LongWritable key, Text value, Context context)
            throws IOException, InterruptedException {

        // TextをReaderに変換
        Reader reader = new StringReader(value.toString());

        // JapaneseAnalyzerで解析(userdicはnull、ModeはNORMALを指定)
        TokenStream stream = new JapaneseTokenizer(reader, null, true,
            JapaneseTokenizer.Mode.NORMAL);

        // Unicode正規化
        stream = new ICUNormalizer2Filter(stream);

        // 小文字に統一
        stream = new LowerCaseFilter(Version.LUCENE_36, stream);

        // こと」「これ」「できる」などの頻出単語を除外
        stream = new StopFilter(Version.LUCENE_36, stream, JapaneseAnalyzer.getDefaultStopSet());

        // 16文字以上の単語は除外(あまり長い文字列はいらないよね)
        stream = new LengthFilter(false, stream, 1, 16);

        // 動詞の活用を揃える(疲れた→疲れる)
        stream = new JapaneseBaseFormFilter(stream);

        // 助詞、助動詞、接続詞などを除外する
        stream = new JapanesePartOfSpeechStopFilter(false, stream, JapaneseAnalyzer.getDefaultStopTags());

        // カタカナ長音の表記揺れを吸収
        stream = new JapaneseKatakanaStemFilter(stream);

        // 結果を出力
        while (stream.incrementToken()) {
            CharTermAttribute term = stream.getAttribute(CharTermAttribute.class);
            text.set(term.toString());
            context.write(text, one);
        }
    }
}

コード全文はこちら

これを利用すると、下記のように単語が抽出されます。

これはある精神病院の患者、――第二十三号がだれにでもしゃべる話である。彼はもう三十を越しているであろう。が、一見したところはいかにも若々しい狂人である。

  ↓

精神	病院	患者	第	二	十	三	号	だれ	しゃべる	話	彼	もう	三	十	越す	一見	いかにも	若々しい	狂人

助詞や句読点、「これ」「あれ」「ある」などが除外されたり、「越して」が基本形の「越す」に変換されたりしています。

StopFilterやJapanesePartOfSpeechStopFilterは一部の単語を削ってしまうので、使用する際は要件と相談。

利用できるフィルタについて

下記参照。

TokenFilterのJavaDocのDirect Known Subclassesが参考になる
http://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/api/all/org/apache/lucene/analysis/TokenFilter.html

Luceneのフィルタ一覧
http://www.mwsoft.jp/programming/lucene/lucene_filter.html

英語文書を解析してみる

以下のパターンで英語の文書に対してWordCountを実行して、出現する単語数と実行時間がどの程度変わるかを見てみる。

  1. Tokenizerを使わず、空白文字でSplitのみ行なう
  2. Tokenizerを使わず、空白文字でSplitしてLowerCase
  3. StandardTokenizer + LowerCaseFilter
  4. StandardTokenizer + LowerCaseFilter + ASCIIFoldingFilter + EnglishPossessiveFilter + KStemFilter
  5. StandardTokenizer + LowerCaseFilter + ASCIIFoldingFilter + EnglishPossessiveFilter + KStemFilter + StopFilter + LengthFilter

実行したマシンはJobTracker1台、TaskTracker2台の構成。TaskTrackerのマシンはCore2DuoでMapperは1台で3つまで並列可能とする(2コアだけど動かしてみたら3の方が速かった)。

対象ファイルは英語のWikipediaのabstract.xmlから概要のところのテキストを抽出したもの。サイズは464MB。Mapperで1ずつカウントし、combinerで集約。

表の出現単語数は出現したユニークな単語の数。

出現単語数出現単語数(%)実行時間実行時間(%)
13329886100%1:39100.0%
2312975294.0%1:44105.1%
3157157447.2%2:17138.4%
4140808742.3%2:42163.6%
5139416941.9%2:43164.6%

StandardTokenizerは記号などの余計な文字を取り去るため、英語文書を対象とした場合は、単語数は空白文字(\s)で区切った時の半分程度になった。

例えば空白文字でsplitした結果には「but!」「"big"」「$1000」のような記号を含んだ単語が多く見られるが、StandardTokenizerを使った場合は記号が除去され「but」「big」「1000」として扱われている。

但し、StandardTokenizerは日本語等をuni-gramとして扱うため、複数の言語が混ざった文書では逆に単語数が増える場合もある。

実行コードは以下。
https://github.com/mwsoft/sample/tree/master/hadoop-lucene-tokenizer/src/main/java/jp/mwsoft/sample/hadoop/lucene_filter

実行時間は多少増えているが、十分現実的な範囲。

日本語文書を解析してみる

以下のパターンで英語の文書に対してWordCountを実行して、出現する単語数と実行時間がどの程度変わるかを見てみる。

  1. Tokenizerを使わず、Kuromojiで形態素解析
  2. JapaneseTokenizerのみ利用
  3. JapaneseTokenizerのみ利用(SearchMode使用)
  4. JapaneseTokenizer + ICUNormalizer2Filter + LowerCaseFilter
  5. JapaneseTokenizer + ICUNormalizer2Filter + LowerCaseFilter + JapaneseKatakanaStemFilter
  6. JapaneseTokenizer + ICUNormalizer2Filter + LowerCaseFilter + JapaneseKatakanaStemFilter + JapaneseBaseFormFilter
  7. JapaneseTokenizer + ICUNormalizer2Filter + LowerCaseFilter + JapaneseKatakanaStemFilter + JapaneseBaseFormFilter + StopFilter + LengthFilter
  8. JapaneseTokenizer + ICUNormalizer2Filter + LowerCaseFilter + JapaneseKatakanaStemFilter + JapaneseBaseFormFilter + StopFilter + LengthFilter + JapanesePartOfSpeechStopFilter

対象ファイルはTwitterのStreamingAPIから収集した1ヶ月分のTweetから日本語(平仮名もしくはカタカナが含まれていることで判定)のみ抽出したものを利用。Tweet数は440万件。ファイルサイズは約450MB。

出現単語数出現単語数(%)実行時間実行時間(%)
11787577100.0%4:3410.0%
2166851193.3%3:4481.8%
3167515593.7%4:0087.6%
4149497783.6%3:5786.5%
5148882583.3%3:5987.2%
6146576282.0%4:0388.7%
7145419481.3%3:4682.5%
8144311280.7%3:4381.4%

フィルタをかけるたびに少しずつ揺れが統一されてユニーク単語数が減っている。単語を削ることをしないUnicode正規化、LowerCase、Stemmer、BaseFormFilterなどの処理だけでも十分に効果があるようです。

実行時間はどれもそれほど変わらず。フィルタをかけても実行時間が長くなるということはなさそう。