Luceneは3.6から形態素解析機能も入って日本語文書が手軽に扱えるようになった。
Hadoopを使う際にこれらの機能を利用すれば何かと便利なんではなかろうかと思ったので、サンプルコードを書いてみた。
英語文書を単語に分割してカウントする処理を書いてみる。
下記は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を実行して、出現する単語数と実行時間がどの程度変わるかを見てみる。
実行したマシンはJobTracker1台、TaskTracker2台の構成。TaskTrackerのマシンはCore2DuoでMapperは1台で3つまで並列可能とする(2コアだけど動かしてみたら3の方が速かった)。
対象ファイルは英語のWikipediaのabstract.xmlから概要のところのテキストを抽出したもの。サイズは464MB。Mapperで1ずつカウントし、combinerで集約。
表の出現単語数は出現したユニークな単語の数。
出現単語数 | 出現単語数(%) | 実行時間 | 実行時間(%) | |
---|---|---|---|---|
1 | 3329886 | 100% | 1:39 | 100.0% |
2 | 3129752 | 94.0% | 1:44 | 105.1% |
3 | 1571574 | 47.2% | 2:17 | 138.4% |
4 | 1408087 | 42.3% | 2:42 | 163.6% |
5 | 1394169 | 41.9% | 2:43 | 164.6% |
StandardTokenizerは記号などの余計な文字を取り去るため、英語文書を対象とした場合は、単語数は空白文字(\s)で区切った時の半分程度になった。
例えば空白文字でsplitした結果には「but!」「"big"」「$1000」のような記号を含んだ単語が多く見られるが、StandardTokenizerを使った場合は記号が除去され「but」「big」「1000」として扱われている。
但し、StandardTokenizerは日本語等をuni-gramとして扱うため、複数の言語が混ざった文書では逆に単語数が増える場合もある。
実行時間は多少増えているが、十分現実的な範囲。
以下のパターンで英語の文書に対してWordCountを実行して、出現する単語数と実行時間がどの程度変わるかを見てみる。
対象ファイルはTwitterのStreamingAPIから収集した1ヶ月分のTweetから日本語(平仮名もしくはカタカナが含まれていることで判定)のみ抽出したものを利用。Tweet数は440万件。ファイルサイズは約450MB。
出現単語数 | 出現単語数(%) | 実行時間 | 実行時間(%) | |
---|---|---|---|---|
1 | 1787577 | 100.0% | 4:34 | 10.0% |
2 | 1668511 | 93.3% | 3:44 | 81.8% |
3 | 1675155 | 93.7% | 4:00 | 87.6% |
4 | 1494977 | 83.6% | 3:57 | 86.5% |
5 | 1488825 | 83.3% | 3:59 | 87.2% |
6 | 1465762 | 82.0% | 4:03 | 88.7% |
7 | 1454194 | 81.3% | 3:46 | 82.5% |
8 | 1443112 | 80.7% | 3:43 | 81.4% |
フィルタをかけるたびに少しずつ揺れが統一されてユニーク単語数が減っている。単語を削ることをしないUnicode正規化、LowerCase、Stemmer、BaseFormFilterなどの処理だけでも十分に効果があるようです。
実行時間はどれもそれほど変わらず。フィルタをかけても実行時間が長くなるということはなさそう。