Wikipediaのカテゴリと、Luceneの類似文書検索を使って、簡易な文書のカテゴリ判定機能を作る。
仕組みは簡単。まず、Wikipediaの文書とカテゴリをLuceneに放り込んでインデックスを作成する。次にカテゴリを付けたい文書の類似文書を検索する。検索結果に紐づくWikipediaのカテゴリを見ると、なんとなくその文書のカテゴリが判定できるという寸法。
基本的な部分は実際に1日で作れた。けど、そこから精度を上げようとこだわり始めたらいつの間にか年が明けてしまった。
下記ページからjawiki-latest-pages-articles.xml.bz2を取ってくる。
Index of /jawiki/latest/
http://dumps.wikimedia.org/jawiki/latest/
数GBあるので落とすのにけっこう時間がかかる。落とすファイルがどういうものかに関する説明は下記。
http://www.mwsoft.jp/programming/munou/wikipedia_data_list.html
ダウンロードしたXMLファイルをパースするコードを書いて、Luceneのインデックスを作成する。パーサは100行弱で書けた(Scala。import文、空行除く)。
タイトルが「Wikipedia:」「Help:」「ファイル:」で始まる文書は管理用のページなので取り込まない。他に「Category:」や「Template:」などもあるけど、この辺はカテゴリと関連があるので残す。あと「曖昧さ回避」を含むページも取り込まない。
カテゴリはXMLに[[Category:カテゴリ名]]のように記述されているので正規表現で取るか、jawiki-latest-categorylinks.sqlに入っている情報をページIDで引っ張る。自分はこんな正規表現書いた。
\[\[(?i)Category\:([^\|]+)\|?.*?]\]
categoryテーブルの方が情報量は多いので、そっちから取った方がより良いような気もするけど、それで精度が大きく変わるとも思えないので気にしない。まとめると下記のような条件になる。
for ( page <- iterator; if page.title != null; if !page.title.trim.isEmpty; if !page.title.startsWith("Wikipedia:"); if !page.title.startsWith("Help:"); if !page.title.startsWith("ファイル:"); if !page.title.contains("曖昧さ回避"); if page.categories.length > 0 )
この条件で取ると、ドキュメント数は全部で107万件強になる。今回扱ったデータでは、ページ数自体は2,461,588件件。categorylinksテーブルのcl_fromをdistinctしてcountしてみると129万件弱ある。少し削れ過ぎてる気がするけど、まぁいいや。
後は取れた値をLuceneに放りこむ。投入処理にはけっこう時間がかかるので、実行したタイミングで食事休憩を入れたり昼寝をしたりする。うちのPC(AthlonII X4 640。かなり昔のマシン)で90分くらいだった。
コードの詳細はこの資料の末尾に記述する。
JapaneseAnalyzerはデフォルトのモードがSEARCHになっているので、NORMALにしておく。類似文書検索ではそちらの方が効果が良いはず。たぶん。きっと。
ユーザ辞書はほぼ未使用。Wikipediaのタイトルをすべてユーザ辞書に入れてみたところ、結果が悪くなった。例えば姓名を連結したものを辞書に入れたりすると(安倍晋三のような)、姓名どちらかだけではヒットしなくなるのでよろしくない。一般的な姓や名を個別に登録したり、形態素解析の邪魔にならないような地名、固有名詞などを入れる分には良いと思う。
URLを指定して、コンテンツを取ってきて、類似文書検索をするようなコードを書く。
まずはMoreLikeThisのクエリを作る。下記のような感じにした。MaxDocFreqは設定すべきかどうか少し悩むところ。Titleはインデックス登録時にboostを5.0fにしている。Microsoftのなんかの論文で、手法や用途は別だけど重みを10倍にしてるのを読んだ記憶がある。
def createQuery(indexReader: IndexReader, contentReader: Reader): Query = { val mlt = new MoreLikeThis(indexReader) mlt.setMinTermFreq(1) mlt.setMinDocFreq(1) mlt.setMaxDocFreq(100000) mlt.setMaxQueryTerms(50) mlt.setFieldNames(Array("title", "text")) mlt.setAnalyzer(Globals.analyzer) mlt.setBoost(true) mlt.like(contentReader, "text") }
あとは下記のようなコードで結果一覧が取れる。URLを指定して、Jsoupで中のテキストを取って、そのReaderで検索するイメージ。
case class MltResult(id: Int, title: String, categories: Array[String], score: Float) val searcher = new IndexSearcher(indexReader) val contentReader = new StringReader(Jsoup.connect(url).get.text()) val query = createQuery(indexReader, contentReader) val collector = TopScoreDocCollector.create(1000, false) searcher.search(query, collector) (for (hit <- collector.topDocs(0, 30).scoreDocs) yield { val doc = searcher.doc(hit.doc) MltResult( doc.get("id").toString.toInt, doc.get("title").toString, doc.get("categories").toString.split("\t"), hit.score) }).toList
これでうちのサイトのトップページを解析させたところ、下記のような文書が類似と判定された。それっぽくはある。
R言語 | List(数値解析ソフトウェア, 統計処理ツール, オープンソース, GNUプロジェクト, オブジェクト指向言語, 関数型言語, プログラミング言語) オーバーレイ (コンピュータ用語) | List(オペレーティングシステムの仕組み, プログラミング, DTP) プログラミング用語 (分野別) | List(情報工学, プログラミング, コンピュータの一覧) メモ化 | List(プログラミング言語, 最適化) F-01C | List(携帯電話端末 (NTTドコモ 第三世代), 携帯電話端末 (富士通), 国際ローミング対応機種, Bluetooth搭載機器, 防水機種, 防塵機種)
score(類似度が高いドキュメントほど高くなる)を加算する形で、簡単にカテゴリを出してみる。下記のようなイメージ。
val categories = (for (page <- searchResult) yield page.categories.map(_ -> page.score)) .flatMap(x => x) .groupBy(x => x._1) .map(x => x._1 -> x._2.map(_._2).sum) .toList.sortBy(_._2).reverse.slice(0, 10)
検索結果の取得数を上位50件にして、うちのサイトのカテゴリを出すと、下記のようになった。
(プログラミング言語,1.0885683) (プログラミング,0.7787105) (オブジェクト指向言語,0.523203) (オープンソース,0.4186293) (スクリプト言語,0.40926307) (コンピュータの一覧,0.32439438) (関数型言語,0.30185556) (携帯電話端末 (NTTドコモ 第三世代),0.3011808) (Bluetooth搭載機器,0.3011808) (防水機種,0.3011808)
下の方が少し携帯に寄ってしまっているのが気になるけど、プログラミングが上に来たのは良い感じ。
他のページも試してみる。ダルビッシュ有投手の公式ブログ。
(存命人物,1.5776927) (日本の野球選手,0.76998) (大阪府出身の人物,0.565331) (ワールド・ベースボール・クラシック日本代表選手,0.54332525) (沢村栄治賞,0.54200274) (最優秀防御率 (NPB),0.54200274) (オリンピック野球日本代表選手,0.48494425) (最多奪三振 (NPB),0.48061335) (エイベックス所属者,0.46923018) (最優秀選手 (NPB),0.438853)
田中将大投手の公式ブログだとこうなる。紅白とかももクロとかの話が書いてあったので、すっかりそれっぽい色に染まってしまった。間違ってはいない。
(存命人物,3.202575) (日本のアイドル,1.8241236) (日本の歌手,0.8976136) (日本のアイドルグループ,0.88073593) (東京都出身の人物,0.80683833) (NHK紅白歌合戦出演者,0.785785) (芸能人女子フットサル選手,0.72424865) (日本の俳優,0.7111861) (過去のAKB48グループ所属者,0.5939772) (日本のタレント,0.5673351)
このままではカテゴリ分類としては不完全なので、こちらで用意したカテゴリ一覧にマッチングさせてあげたい。たとえば下記みたいな感じで。
政治 経済 スポーツ 芸能 コンピュータ サイエンス ...
単純な作戦としては、Wikipedia上の各カテゴリと、実際に割り振りたいカテゴリの変換テーブル的なものを書けばなんとかなる。カテゴリ一覧はpageテーブルの情報から取れる。
select cat_title from category where cat_pages >= 10;
1つの記事にしか付いてないようなカテゴリまで手を出すと18万件以上ある。上記のように10件以上付いてるカテゴリに絞っても6万5千件。死んだ魚のような眼をしながら片っ端から変換表に加えていく作業をしても、2週間くらいかかるんじゃないだろうか。
50件以上付いてるカテゴリだと1万6千。100件以上だと7千件。7千件なら2日あればなんとか。けど、そこまで削ってしまうと必要なカテゴリまで削られてしまう。たとえば「自然言語処理」カテゴリは42個しか項目がいないので、この条件だと省かれる。
今回は100件以上のカテゴリを抽出して、「アダルトビデオのカテゴリ多すぎるのなんとかしてください」と愚痴をいいながら気力が続く限り(約5時間)手作業でカテゴリを付加してみた。結果、当サイトは下記のようなカテゴリに分類されるようになった。
(コンピュータ,3.2135575) (プログラミング,1.8672788) (ソフトウェア,0.9609959) (ビデオゲーム,0.61939573) (ゲーム,0.61939573)
頻出するカテゴリの中にはプログラミング系の子が少ないので、コンピュータの方が強くなっている。カテゴリにもう少し手を入れれば、プログラミングが強くなるはず。
でも、これ人手でやる作業じゃないな。もっと自動化できる方向を考えよう。いつかそのうち。(今日はもうやらない)
とりあえずこれで最低限の分類はできるようになった。形態素解析したり、文書における単語の重み出したり、類似度を計算したりといった箇所を全部Lucene任せにしているので、非常に簡単なコードでカテゴリ判定が実現できている。100万件のラベルあり文書をさくっと使えるのも素晴らしい。
問題点としては、まず求めたいカテゴリとWikipedia上のカテゴリの割り当てを自動化しないとやってられない。適当に各カテゴリをクラスタリングして類似の子にまとめてラベル貼るとか、他の教師データと紐づけて各カテゴリの繋がりやすさを出すとか、やれそうな方法はいろいろある。カテゴリを階層化してる人とかもいませんでしたっけ。前に見た記憶があるようなないような。気が向いたらいろいろ試す。
次に、処理が重い。けっこう大きなクエリを投げるので、類似文書検索で2〜3秒かかる。Luceneのインデックスはできてるので、lucene.vectorあたり使って、そのままMahoutでモデルを作れば、軽い処理で精度的にもより良いものができるはず。現行の処理だと特徴語に引っ張られやす過ぎるので、その辺を良い感じに調整したモデルができたらなお良い。
あとはデフォルトの辞書ではやはり不十分だったり(例えば田中将大の「将大」が辞書には入ってないので、インデックスが「田中 | 将 | 大」で貼られていたり)、Webページのカテゴリ分類をしたい時は本文抽出も必要だよねとか、Wikipediaにはないジャンルの分類はできないよねとか、いろいろ課題は存在する。本文抽出は下手にやると精度が下がるので悩ましい。
XMLのパーサとLucene4.5でのMoreLikeThisの記述あたりは需要がありそうなので、Githubに置いておきます。
パーサは jp.mwsoft.wikipedia.parser.PageArticleParser、MoreLikeThis検索は jp.mwsoft.wikipedia.categorizer.MltSearcher でやってます。
使い方はReadme参照。TypeSafe Activator込みでコミットしてあるので、JDKが入ってればコマンド叩くだけで一応動くはず。