概要

Mahoutのexamples/binの下には、cluster-reuters.shというロイターの記事をクラスタリングする処理を実演してくれるシェルがいる。

このシェルでは、seqdirectoryseq2sparseという2つのコマンドを使って、テキスト文書をVectorに変換している。

これを参考にして、青空文庫から取ってきたいくつかの文書をクラスタリングして遊んでみる。

Mahoutのバージョンは0.7

@CretedDate 2012/08/29
@Env Java7, Mahout0.7

seqdirectory

bin/mahout seqdirectoryは、テキストファイルの入ったディレクトリから、シーケンスファイルを生成する。

とりあえず下記のようなテキストファイルを適当なディレクトリ(仮に/tmp/tekitou/sample.txtとする)に入れて、実行してみる。

我輩はプログラマである。
仕事はしていない。

ファイルはHDFSに置く。

$ hadoop fs -put sample.txt  /tmp/tekitou/sample.txt

seqdirectory実行。

$ bin/mahout seqdirectory -i /tmp/tekitou -o /tmp/seqdir

-oで指定したパス(HDFS上の/tmp/seqdir)にファイルができている。

$ hadoop fs -ls /tmp/seqdir

Found 1 items
-rw-r--r--   1 user supergroup         78 xxxx-xx-xx xx:xx /tmp/seqdir/chunk-0

seqdumperで中身を見てみる。

$ bin/mahout seqdumper -i /tmp/seqdir/chunk-0

Input Path: /tmp/tekitou2/chunk-0
Key class: class org.apache.hadoop.io.Text Value Class: class org.apache.hadoop.io.Text
Key: /sample.txt: Value: 我輩はプログラマである。
仕事はしていない。
Count: 1
12/08/27 23:18:50 INFO driver.MahoutDriver: Program took 653 ms (Minutes: 0.010883333333333333)

Keyにファイル名が、Valueに本文が入ったレコードが1つ登録されている。

seqdirectoryはこんな感じで1ファイル1レコードのシーケンスファイルを作成してくれるらしい。

seq2sparse

seq2sparseを使うと、LuceneのAnalyzerを利用しつつ、いい感じのVectorファイルを作ってくれる。

デフォルトで使われるAnalyzerはStandardAnalyzer。英語はスペースで区切り、日本語はunigramにする子。

試しに引数等は指定せずにそのまま実行してみる。

$ bin/mahout seq2sparse -i /tmp/seqdir -o /tmp/sparse

出来上がったファイルを見てみる。

$ hadoop fs -ls /tmp/sparse

drwxr-xr-x   - user supergroup          0 xxxx-xx-xx xx:xx /tmp/sparse/df-count
-rw-r--r--   1 user supergroup        117 xxxx-xx-xx xx:xx /tmp/sparse/dictionary.file-0
-rw-r--r--   1 user supergroup        133 xxxx-xx-xx xx:xx /tmp/sparse/frequency.file-0
drwxr-xr-x   - user supergroup          0 xxxx-xx-xx xx:xx /tmp/sparse/tf-vectors
drwxr-xr-x   - user supergroup          0 xxxx-xx-xx xx:xx /tmp/sparse/tfidf-vectors
drwxr-xr-x   - user supergroup          0 xxxx-xx-xx xx:xx /tmp/sparse/tokenized-documents
drwxr-xr-x   - user supergroup          0 xxxx-xx-xx xx:xx /tmp/sparse/wordcount

なにやらいろいろファイルが出力されている。辞書出現数TF/IDF系の子など。内容はだいたい名前から想像できる通り。

dictionary.file-0は各単語とIDを紐付けるための辞書になっている。seqdumperで中身を見てみる。

$ bin/mahout seqdumper -i /tmp/sparse/dictionary.file-0

Key class: class org.apache.hadoop.io.Text Value Class: class org.apache.hadoop.io.IntWritable
Key: い: Value: 0
Key: は: Value: 1
Count: 2

「い」と「は」の二語だけが入っている。

「い」はIDが0、「は」はIDが1。この2つの文字は文中に2回出現しているので有効なものとして取り出されている。他の文字は出現数が1回なので、足切りされている。

この辺の動きは引数によって変えられる。

次にtokenized-documentsの中身も見てみる。ここにはTokenizeされた結果が入っている。

$ bin/mahout seqdumper -i /tmp/sparse/tokenized-documents

Key: /sample.txt: Value: [我, 輩, は, プログラマ, で, あ, る, 仕, 事, は, し, て, い, な, い]

あれ、StandardAnalyzerってカタカナはunigramにしないんだっけか。プログラマが一語で扱われている。

とりあえずこんな風にTokenizeされて、その結果から出現数をカウントしているらしい。

このままだと平仮名や漢字は文字数をカウントするだけになってしまうので、mecabでわかち書きした後に、AnalyzerにWhiteSpaceAnalyzer(単純なスペース区切りをするAnalyzer)を指定して実行してみることにする。

JapaneseAnalyzerを使うことを考える

最初はMeCabじゃなくてLucene3.6から入ったJapaneseAnalyzerを利用しようとしてみた。

けど、seq2sparseでJapaneseAnalyzerやCJKAnalyzerを指定したらエラーになった。どうやら引数なしのコンストラクタがあるAnalyzerしか使えないらしい。

自前で引数なしコンストラクタがあるJapaneseAnalyzerを継承したAnalyzerを書いてjarを作り、ローカルのMavenレポジトリに置いて、examplesとintegrationのpom.xmlにdependencyを追加してmvn installし直したら、普通に使えた。

けど、そこまでやるくらいなら、org.apache.mahout.vectorizer.SparseVectorsFromSequenceFilesの204行目(ClassUtils.instantiateAs(analyzerClass, Analyzer.class))を書き換えて、Versionを引数に取るようにする方が現実的かもしれない。

いや、そこまでやるならむしろlucene.vector。

と、そんなことをうだうだとやった結果、今回はMeCabで済ませた。

クラスタリング用データの準備

seq2sparseする前に、実際にクラスタリングするデータを用意しておく。青空文庫から以下の20文書を取得。

源氏物語   : 桐壷、夕顔、若紫、夢浮橋
太宰治     : 斜陽、人間失格、走れメロス
宮沢賢治   : セロ弾きのゴーシュ、銀河鉄道の夜、注文の多い料理店
夢野久作   : 少女地獄、ドグラマグラ、いなか、の、じけん
夏目漱石   : こころ、三四郎、それから、門
芥川龍之介 : 羅生門、蜜柑、河童

クラスタリングしたら、源氏物語夏目漱石の三部作あたりはうまく分類されないかなぁ、という感じの取り揃え。他はうまく分類されないと思われる。

わかち書きしてseq2sparse

とりあえず、集めた文書をmecabでわかち書きする。ファイルは拡張子を.txtとし、文字コードはUTF-8で保存している。

for file in `find -type f -name "*.txt"`
do
  mecab -O wakati -o $file.wakati $file
done

上記シェルで、配下の「.txt」ファイルがわかち書きされて、「.wakati」が末尾についたファイルが生成される。

出来たファイルを/tmp/aozoraに置く。

$ hadoop fs -mkdir /tmp/aozora
$ hadoop fs -put *.wakati /tmp/aozora

# ちゃんと置けてるか確認
$ hadoop fs -ls /tmp/aozora

次にこのディレクトリに対してseqdirectoryして、「ファイル名 - 本文」のシーケンスファイルにする。

$ bin/mahout seqdirectory -i /tmp/aozora -o /tmp/aozora_seq

最後にseq2sparseを実行する。「-a」でAnalyzerをWhiteSpaceAnalyzerに指定。

$ bin/mahout seq2sparse \
  -i /tmp/aozora_seq \
  -o /tmp/aozora_sparse \
  -a org.apache.lucene.analysis.WhitespaceAnalyzer

これでクラスタリングに必要なVectorファイルが生成された。

seq2sparseの実行結果確認

とりあえず出現数が上位10件の単語を見てみる。ソートはされてないので、自前でsort。

$ bin/mahout seqdumper -i /tmp/aozora_sparse/wordcount | sort -nrk4 -t: wordcount.txt | head

Key: 、: Value: 55406
Key: の: Value: 54271
Key: (: Value: 40817
Key: ): Value: 40792
Key: 。: Value: 38089
Key: に: Value: 37934
Key: た: Value: 37352
Key: て: Value: 34617
Key: を: Value: 30191
Key: は: Value: 29089

句読点とか助詞とか括弧とかが上位に来ている。括弧は読み仮名のところで使われてるから数が多い。

上位10件には1文字の結果しかいないけどunigramになってるというわけではなく、もう少し下位の方まで見ていくと「その:5245」「自分:2383」「という:2071」などの複数文字の言葉も出現するようになる。

クラスタリングする

seq2sparseで生成されたファイルを利用してクラスタリングしてみる。手法はcanopy → kmeansを利用。

Vectorファイルにはtfidf-vectorsディレクトリを指定する。名前の通り、TF/IDFを使って単語ごとの重みを加味したVector。

距離関数はCosineDistanceMeasureを利用。

-t1-t2は適当に0.5と0.7を指定。大き過ぎる気もするけど、これでも文書20個に対して出力されるクラスタ数は7になった。ちょっと無理矢理感はある。

下記コマンドでCanopyクラスタリングを実行。

$ bin/mahout canopy -i /tmp/aozora_sparse/tfidf-vectors -o /tmp/aozora_canopy -t1 0.5 -t2 0.7 -dm org.apache.mahout.common.distance.CosineDistanceMeasure

clusterdumperで結果を見てみる。そのままだと読みづらいので、seq2sparse時に生成した辞書を指定してやる。

$ bin/mahout clusterdump -i /tmp/aozora_canopy/clusters-0-final/part-r-00000 -d /tmp/aozora_sparse/dictionary.file-0 -dt sequencefile -o dump

下記は出力されたTop Temrsの一部。

メロス => 28.791271209716797, セリヌンティウス => 12.790857315063477, 友  => 10.124189376831055, 私 =>  9.636309623718262, 王 => 9.606895446777344

ゴーシュ => 31.156524658203125, セロ => 17.382720947265625, まし => 14.104578971862793, 弾き => 14.01168155670166
かっこう => 12.565958976745605

僧都 => 13.58869743347168, 尼 => 11.662327766418457, 薫 => 10.437751770019531, こと => 10.407272338867188, 浮舟 => 9.341120719909668

下人 => 19.217321395874023, 老婆 => 15.330117225646973, 饑死 => 9.341120719909668, 羅生門 => 8.737818717956543, 云う => 7.839513778686523

特徴的な言葉のある作品の影響が強く出た結果になっている。源氏物語はまとまりそうだけど、夏目漱石は無理っぽいかな。

k-means

Canopyの結果を使ってk-meansしてみる。

$ bin/mahout kmeans -i /tmp/aozora_sparse/tfidf-vectors -c /tmp/aozora_canopy/clusters-0-final/part-r-00000 -o /tmp/aozora_kmeans --maxIter 10 -cl

クラスタリングされたclusteredPointsの中を見てみる。

$ bin/mahout seqdumper -i /tmp/aozora_kmeans/clusteredPoints/ -b 80

Key: 0: Value: 1.0: [1:2.050, 4:2.933, 6:4.273, 7:3.597, 15:3.597, 25:2.897, 30:2.386, 35:2.899
Key: 0: Value: 1.0: [1:8.452, 4:2.933, 6:10.358, 7:8.242, 8:3.690, 10:2.897, 12:1.916, 13:2.897
Key: 0: Value: 1.0: [0:5.720, 1:6.149, 4:2.933, 6:5.447, 7:4.022, 8:3.690, 12:1.916, 15:4.022, 
Key: 0: Value: 1.0: [1:4.100, 4:3.386, 6:6.043, 7:4.758, 12:1.916, 14:5.720, 15:4.758, 16:3.690
  ・
  ・
  ・

クラスタリングされてるっぽいことは分かるけど、これでは何が何やら。下記コマンドのように辞書を噛ませることで、もう少しわかりやすい結果が出るけど、これでもどれがどのファイルを指すのか分かりづらい。

$ bin/mahout clusterdump \
   -d /tmp/aozora_sparse/dictionary.file-0 \
   -dt sequencefile \
   -i /tmp/aozora_kmeans/clusters-1-final \
   -o dump \
   -p /tmp/aozora_kmeans/clusteredPoints

最も近いClusterを計算する

出力されたClusterと各文書のVectorのdistanceを測って、最も近い重心を再計算するという二度手間なことをやって、各文書が所属するクラスタを特定してみる。

とりあえずHDFS上にいる必要なファイルを落とす。HDFSを直で読むこともできるけど今回はローカルで作業する。

$ hadoop fs -get /tmp/aozora_sparse/tfidf-vectors/part-r-00000 vectors.seq
$ hadoop fs -get /tmp/aozora_kmeans/clusters-*-final/part-r-00000 clusters.seq

落としてきたファイルを読み込んで、最も近いクラスタを表示するコードを書く。

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

    Configuration conf = new Configuration();
    DistanceMeasure measure = new CosineDistanceMeasure();

    // Clusterの情報をMapに入れる
    Path clusterPath = new Path("clusters.seq");
    Map<Integer, Vector> map = new HashMap<>();
    for (Pair<Writable, Writable> record : new SequenceFileIterable<Writable, Writable>(
            clusterPath, true, conf)) {
        int clusterId = ((IntWritable) record.getFirst()).get();
        ClusterWritable value = (ClusterWritable) record.getSecond();
        map.put(clusterId, value.getValue().getCenter());
    }

    // 各文書のVectorとClusterのCenterとの距離を見て、最も近いクラスタを表示
    Path vectorPath = new Path("vectors.seq");
    for (Pair<Writable, Writable> record : new SequenceFileIterable<Writable, Writable>(
            vectorPath, true, conf)) {
        String title = ((Text) record.getFirst()).toString();
        Vector vec = ((VectorWritable) record.getSecond()).get();

        double min = Double.MAX_VALUE;
        int cluster = -1;
        for (Map.Entry<Integer, Vector> entry : map.entrySet()) {
            double d = measure.distance(entry.getValue(), vec);
            if (min > d) {
                min = d;
                cluster = entry.getKey();
            }
        }

        System.out.println(cluster + " " + title + " : d=" + min);
    }
}

こんな感じの結果になった。

先頭の数字がクラスタの番号。dが距離。

0 /夏目漱石_こころ.txt.wakati : d=0.26191651897424606
0 /夏目漱石_それから.txt.wakati : d=0.3610761378318097
0 /夏目漱石_三四郎.txt.wakati : d=0.34688616096707103
0 /夏目漱石_門.txt.wakati : d=0.2898903200102828
0 /夢野久作_いなかのじけん.txt.wakati : d=0.4437281122004727
0 /夢野久作_ドグラマグラ.txt.wakati : d=0.22283895129647135
0 /夢野久作_少女地獄.txt.wakati : d=0.3338332437002679
0 /太宰治_人間失格.txt.wakati : d=0.41292863605352714
0 /太宰治_斜陽.txt.wakati : d=0.4095012142660607
0 /芥川龍之介_河童.txt.wakati : d=0.5262440500368326
1 /太宰治_走れメロス.txt.wakati : d=0.0
2 /宮沢賢治_セロ弾きのゴーシュ.txt.wakati : d=0.3105263070151002
2 /宮沢賢治_銀河鉄道の夜.txt.wakati : d=0.08037590266567762
3 /宮沢賢治_注文の多い料理店.txt.wakati : d=2.220446049250313E-16
4 /源氏物語_夕顔.txt.wakati : d=0.1765109135064632
4 /源氏物語_夢浮橋.txt.wakati : d=0.36265773922572275
4 /源氏物語_桐壷.txt.wakati : d=0.2928270626678726
4 /源氏物語_若紫.txt.wakati : d=0.15811930371276706
5 /芥川龍之介_羅生門.txt.wakati : d=0.0
6 /芥川龍之介_蜜柑.txt.wakati : d=0.0

とりあえず源氏物語はまとまった。夕顔と若紫が近くて、夢浮橋が少し遠いところがなんか良い。

Canopyのt1とt2を小さくしてやり直してみる

やはり0.5、0.7はやり過ぎだった気がするので、0.4、0.6くらいでやり直してみる。

まずはキャ○ピーでクラスタリング

$ bin/mahout canopy -i /tmp/aozora_sparse/tfidf-vectors -o /tmp/aozora_canopy2 -t1 0.4 -t2 0.6 -dm org.apache.mahout.common.distance.CosineDistanceMeasure

$ bin/mahout seqdumper -i /tmp/aozora_canopy2/clusters-0-final/part-r-00000

上記コマンドで出力されたクラスタは11個

これをデフォルトの重心としてkmeansしてみる。

$ bin/mahout kmeans -i /tmp/aozora_sparse/tfidf-vectors -c /tmp/aozora_canopy2/clusters-0-final/part-r-00000 -o /tmp/aozora_kmeans2 --maxIter 10 -cl

結果、こんな感じになった。

0 /夏目漱石_こころ.txt.wakati : d=0.2179283383484072
0 /夏目漱石_それから.txt.wakati : d=0.26090680360172447
0 /夏目漱石_三四郎.txt.wakati : d=0.2992974340577935
0 /夏目漱石_門.txt.wakati : d=0.22147560177467496
0 /夢野久作_少女地獄.txt.wakati : d=0.38350869337124993
1 /夢野久作_いなかのじけん.txt.wakati : d=0.3595110967031532
1 /夢野久作_ドグラマグラ.txt.wakati : d=0.023774724981498685
2 /太宰治_人間失格.txt.wakati : d=0.15520902760354494
2 /太宰治_斜陽.txt.wakati : d=0.12476783500955002
3 /太宰治_走れメロス.txt.wakati : d=0.0
4 /宮沢賢治_セロ弾きのゴーシュ.txt.wakati : d=0.0
5 /宮沢賢治_注文の多い料理店.txt.wakati : d=2.220446049250313E-16
6 /宮沢賢治_銀河鉄道の夜.txt.wakati : d=0.0
7 /源氏物語_夕顔.txt.wakati : d=0.1765109135064632
7 /源氏物語_夢の浮橋.txt.wakati : d=0.36265773922572275
7 /源氏物語_桐壷.txt.wakati : d=0.2928270626678726
7 /源氏物語_若紫.txt.wakati : d=0.15811930371276706
8 /芥川龍之介_河童.txt.wakati : d=0.0
9 /芥川龍之介_羅生門.txt.wakati : d=0.0
10 /芥川龍之介_蜜柑.txt.wakati : d=0.0

そこそこに作家ごとにまとまった、そこそこにそれっぽい結果になっている。

それぞれのクラスタの特徴語も見てみる。

$ bin/mahout clusterdump \
   -d /tmp/aozora_sparse/dictionary.file-0 \
   -dt sequencefile \
   -i /tmp/aozora_kmeans2/clusters-1-final \
   -p /tmp/aozora_kmeans2/clusteredPoints \
   -o dump

# クラスタ0(夏目漱石)のTop Terms
助  「  」  事  です  私  三四郎  先生  つて  いる

# クラスタ1(夢野久作)のTop Terms
…  正木  若林  御座い  一郎  いる  事  」  「  吾輩

# クラスタ2(太宰治)のTop Terms
お母さま  」  「  事  私  自分  直治  です  ?  堀木

# クラスタ3(走れメロス)のTop Terms
メロス  セリヌンティウス  友  私  王  群衆  おまえ  暴君  濁流  」

# クラスタ4(セロ弾きのゴーシュ)のTop Terms
ゴーシュ  セロ  まし  弾き  かっこう  楽長  狸  」  「  云い

# クラスタ5(注文の多い料理店)のTop Terms
」  「  まし  クリーム  扉  裏側  がたがた  ぼく  歓迎  かさかさ

# クラスタ6(銀河鉄道の夜)のTop Terms
ジョバンニ  カムパネルラ  まし  「  」  云い  天の川  鷺  銀河  野原

# クラスタ7(源氏物語)のTop Terms
源氏  こと  源  氏  なっ  言っ  惟光  」  「  ます

# クラスタ8(河童)のTop Terms
河童  僕  トック  ゲエル  雌  ラップ  マッグ  です  まし  チャック

# クラスタ9(羅生門)のTop Terms
下人  老婆  饑死  羅生門  云う  太刀  にきび  面皰  盗人  事

# クラスタ10(蜜柑)のTop Terms
小娘  隧道  踏切り  夕刊  私  心もち  霜焼け  暮色  皸  枯草

「」が出てくることが多い。この辺、seq2sparseの引数の指定でそこそこ調節できたはず。

とりあえず、seqdirectoryとseq2sparseが何をしているかはだいたい分かった気がするので、今回はこれで良しとする。