Mahoutのexamples/binの下には、cluster-reuters.shというロイターの記事をクラスタリングする処理を実演してくれるシェルがいる。
このシェルでは、seqdirectoryとseq2sparseという2つのコマンドを使って、テキスト文書をVectorに変換している。
これを参考にして、青空文庫から取ってきたいくつかの文書をクラスタリングして遊んでみる。
Mahoutのバージョンは0.7。
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を使うと、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)を指定して実行してみることにする。
最初は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文書を取得。
源氏物語 : 桐壷、夕顔、若紫、夢浮橋 太宰治 : 斜陽、人間失格、走れメロス 宮沢賢治 : セロ弾きのゴーシュ、銀河鉄道の夜、注文の多い料理店 夢野久作 : 少女地獄、ドグラマグラ、いなか、の、じけん 夏目漱石 : こころ、三四郎、それから、門 芥川龍之介 : 羅生門、蜜柑、河童
クラスタリングしたら、源氏物語と夏目漱石の三部作あたりはうまく分類されないかなぁ、という感じの取り揃え。他はうまく分類されないと思われる。
とりあえず、集めた文書を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ファイルが生成された。
とりあえず出現数が上位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
特徴的な言葉のある作品の影響が強く出た結果になっている。源氏物語はまとまりそうだけど、夏目漱石は無理っぽいかな。
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と各文書の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
とりあえず源氏物語はまとまった。夕顔と若紫が近くて、夢浮橋が少し遠いところがなんか良い。
やはり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が何をしているかはだいたい分かった気がするので、今回はこれで良しとする。