CLOVER🍀

That was when it all began.

Re: Clojure/lucene-kuromojiでテキストマイニング入門 〜形態素解析からワードカウントまで〜

過去のエントリを、リライトしたエントリです。

以前、こちらのエントリを見て

Clojure/kuromojiでテキストマイニング入門 〜形態素解析からワードカウントまで〜
http://antibayesian.hateblo.jp/entry/2013/09/10/231334

素のKuromojiを使って形態素解析をしていたところを、LuceneのKuromojiに書き換えたエントリを書きました。

Clojure/lucene-kuromojiでテキストマイニング入門 〜形態素解析からワードカウントまで〜
http://d.hatena.ne.jp/Kazuhira/20130911/1378914422

が、ぶっちゃけこの時はIncanterの部分が分かっていなくて、途中経過まで同じようにできたので後は元のエントリに合わせて…という感じでした。

ここで、Incanterを少し使ってみたので、コードの見直しをしてみたいと思います。

そもそも、何についてのエントリだっけ?

大元のエントリがやろうとしていたのは、こういうテーマです。

テキストマイニング

Clojureでテキストマイニングをしたい!という方がTLにいらっしゃったので、
Clojureという言語とkuromojiという形態素解析器を用いたテキストマイニング入門の記事を書きます。
この記事の通り手を動かすと、様々なテキスト、例えばアンケートの自由記述やブログ、twitterなどの文章に形態素解析を掛け、ワードカウントと呼ばれる、ある単語が何回出現しているのかを解析する手法を使えるようになります。これを利用し、出現単語を頻度順に並べてランキングを作るなどして、その文書の特徴を明らかにするなどが出来るようになります。

Clojure/kuromojiでテキストマイニング入門 ~形態素解析からワードカウントまで~ - あんちべ!

というわけで、形態素解析を行って、ワードカウントをやってみようという内容でした。これをLucene Kuromojiを使ってやります。

環境準備

今回は、1エントリで全部完結させてみましょう。環境の前提は、Javaがインストールされていること、ここのみとします。

ただ、インストールするツールやライブラリなどについて、そんなに細かくは紹介しません。

Leiningenのインストール

実行には、Leiningenというビルドツールが必要になります。

Leiningen
http://leiningen.org/

インストールは、スクリプトを適当なディレクトリにダウンロードして

$ wget -O /path/to/lein https://raw.github.com/technomancy/leiningen/stable/bin/lein

ダウンロードしたスクリプトに実行権限を付与します。

$ sudo chmod 755 /path/to/lein

また、配置したスクリプトにパスを通しておいてください。

本体をインストール。

$ lein self-install

これで、Leiningenのインストールは完了です。

lein-execプラグインのインストール

Leiningenを使うと、ふつうプロジェクトみたいな単位のものを作成するのですが、面倒なのでClojureスクリプトをそのまま実行する形態にします。

ここで、Leiningenのプラグインであるlein-execプラグインをインストールします。

lein-exec
https://github.com/kumarshantanu/lein-exec

Leiningenのインストールが終わると、

$HOME/.lein

というディレクトリができているので、ここに以下のパスで

$HOME/.lein/profiles.clj

このような内容のファイルを作成します。

{:user {:plugins [[lein-exec "[0.3.2,)"]]}}

ファイル自体は存在しないはずなので、新規に作成してくださいね。

設定内容としては、lein-execプラグインの0.3.2および、より新しいバージョンを取得するような記述になっています。固定にしたい場合は、以下の様に書くとよいでしょう。

{:user {:plugins [[lein-exec "0.3.2"]]}}

これで、以下のようなコマンドでClojureスクリプトが実行できるようになります。

$ lein exec [Clojureスクリプト名]

Clojureスクリプトの準備

それでは、形態素解析とワードカウントを行うClojureスクリプトを書いていきます。

ここで作成するClojureスクリプト名は「lucene-clojure-word-count.clj」とし、以下のコマンドで実行しています。

$ lein exec lucene-clojure-word-count.clj
必要な依存関係の定義やimport/requireなど

今回使用するLucene KuromojiやIncanterなどへの依存関係を定義します。また、必要に応じてimportやrequireも記述。

(set! *warn-on-reflection* true)

(require '[leiningen.exec :as exec])

(exec/deps '[[clj-soup/clojure-soup "0.1.1"]
             [org.apache.lucene/lucene-kuromoji "3.6.2"]
             [incanter "1.5.4"]])

(ns lucene.clojure.word.count
    (import (java.io StringReader)
            (org.apache.lucene.analysis Analyzer TokenStream)
            (org.apache.lucene.analysis.ja JapaneseAnalyzer)
            (org.apache.lucene.analysis.ja.tokenattributes BaseFormAttribute InflectionAttribute PartOfSpeechAttribute ReadingAttribute)
            (org.apache.lucene.analysis.tokenattributes CharTermAttribute)
            (org.apache.lucene.util Version))
    (:require [jsoup.soup :as js]
              [incanter.core :as c]
              [incanter.stats :as s]
              [incanter.charts :as ch]))

あんまり関係ないですが、リフレクションに対する警告を有効にしています。

jsoupは主題とは関係ないのですが、後で使用します。

[clj-soup/clojure-soup "0.1.1"]

また、使用するLuceneのバージョンが古いのですが、これはlein-exec経由で実行する場合は、実行時にLeiningen本体がクラスパス上に含まれてしまうらしく、その中にLucene 3.6があるためです。

[org.apache.lucene/lucene-kuromoji "3.6.2"]

よって、残念ながらLucene Kuromojiは3.6.2を選択しています。が、この後で書くコードはLucene 4系でも動作するもののはずですので、必要であればLeiningenプロジェクトの形にして、依存関係の定義を変えていただければと。

形態素解析を行う関数

形態素解析を行う関数を、以下の様に定義しました。

;; Luceneのバージョン
(def ^Version lucene-version (Version/LUCENE_CURRENT))

;; 与えられた文字列を、形態素解析し単語および属性のマップとして
;; ベクタに含めて返却する
(defn morphological-analysis [^String sentence]
  (let [^Analyzer analyzer (JapaneseAnalyzer. lucene-version)]
    (with-open [^TokenStream token-stream (. analyzer tokenStream
                                             ""
                                             (StringReader. sentence))]

      (let [^CharTermAttribute char-term (. token-stream addAttribute CharTermAttribute)
            ^BaseFormAttribute base-form (. token-stream addAttribute BaseFormAttribute)
            ^InflectionAttribute inflection (. token-stream addAttribute InflectionAttribute)
            ^PartOfSpeechAttribute part-of-speech (. token-stream addAttribute PartOfSpeechAttribute)
            ^ReadingAttribute reading (. token-stream addAttribute ReadingAttribute)]

        (letfn [(create-attributes []
                  {:token (. char-term toString)
                   :reading (. reading getReading)
                   :part-of-speech (. part-of-speech getPartOfSpeech)
                   :base (. base-form getBaseForm)
                   :inflection-type (. inflection getInflectionType)
                   :inflection-form (. inflection getInflectionForm)})]
          (. token-stream reset)

          (try
            (loop [tokenized-seq []]
              (if (. token-stream incrementToken)
                (recur (conj tokenized-seq (create-attributes)))
                tokenized-seq))
            (finally (. token-stream end))))))))

この関数に、以下のような入力を与えると

(->> (morphological-analysis (str "ClojureとLucene-Kuromojiを使って、"
                                  "テキストを形態素解析してワードカウントを行い、"
                                  "Clojureによるテキストマイニングにチャレンジしようと思います"))
     (println))

このような結果になります。
*実際にはベクタですが、見やすさのため「{」で改行しています。

[
{:token clojure, :reading nil, :part-of-speech 名詞-固有名詞-組織, :base nil, :inflection-type nil, :inflection-form nil} 
{:token lucene, :reading nil, :part-of-speech 名詞-固有名詞-組織, :base nil, :inflection-type nil, :inflection-form nil} 
{:token kuromoji, :reading nil, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token 使う, :reading ツカッ, :part-of-speech 動詞-自立, :base 使う, :inflection-type 五段・ワ行促音便, :inflection-form 連用タ接続} 
{:token テキスト, :reading テキスト, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token 形態素, :reading ケイタイソ, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token 解析, :reading カイセキ, :part-of-speech 名詞-サ変接続, :base nil, :inflection-type nil, :inflection-form nil} 
{:token ワード, :reading ワード, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token カウント, :reading カウント, :part-of-speech 名詞-サ変接続, :base nil, :inflection-type nil, :inflection-form nil} 
{:token 行う, :reading オコナイ, :part-of-speech 動詞-自立, :base 行う, :inflection-type 五段・ワ行促音便, :inflection-form 連用形} 
{:token clojure, :reading nil, :part-of-speech 名詞-固有名詞-組織, :base nil, :inflection-type nil, :inflection-form nil} 
{:token テキスト, :reading テキスト, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token マイニング, :reading マイニング, :part-of-speech 名詞-サ変接続, :base nil, :inflection-type nil, :inflection-form nil} 
{:token チャレンジ, :reading チャレンジ, :part-of-speech 名詞-サ変接続, :base nil, :inflection-type nil, :inflection-form nil} 
{:token 思う, :reading オモイ, :part-of-speech 動詞-自立, :base 思う, :inflection-type 五段・ワ行促音便, :inflection-form 連用形}]
名詞のみを抽出

先ほどの形態素解析の結果から、ワードカウントするために名詞のみを抽出します。そのための、関数定義。

;; morphological-analysis関数で得られたベクタの中から
;; 名詞のみに絞り込む
(defn select-nominal [tokenized-seq]
  (filter #(re-find #"名詞" (% :part-of-speech)) tokenized-seq))

これを、先ほどの関数呼び出しに繋げます。

(->> (morphological-analysis (str "ClojureとLucene-Kuromojiを使って、"
                                  "テキストを形態素解析してワードカウントを行い、"
                                  "Clojureによるテキストマイニングにチャレンジしようと思います"))
     (select-nominal)
     (println))

結果。

(
{:token clojure, :reading nil, :part-of-speech 名詞-固有名詞-組織, :base nil, :inflection-type nil, :inflection-form nil} 
{:token lucene, :reading nil, :part-of-speech 名詞-固有名詞-組織, :base nil, :inflection-type nil, :inflection-form nil} 
{:token kuromoji, :reading nil, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token テキスト, :reading テキスト, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token 形態素, :reading ケイタイソ, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token 解析, :reading カイセキ, :part-of-speech 名詞-サ変接続, :base nil, :inflection-type nil, :inflection-form nil} 
{:token ワード, :reading ワード, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token カウント, :reading カウント, :part-of-speech 名詞-サ変接続, :base nil, :inflection-type nil, :inflection-form nil} 
{:token clojure, :reading nil, :part-of-speech 名詞-固有名詞-組織, :base nil, :inflection-type nil, :inflection-form nil} 
{:token テキスト, :reading テキスト, :part-of-speech 名詞-一般, :base nil, :inflection-type nil, :inflection-form nil} 
{:token マイニング, :reading マイニング, :part-of-speech 名詞-サ変接続, :base nil, :inflection-type nil, :inflection-form nil} 
{:token チャレンジ, :reading チャレンジ, :part-of-speech 名詞-サ変接続, :base nil, :inflection-type nil, :inflection-form nil})

動詞が落とされましたね。あと、表示がシーケンスになりましたが…。

単語のみの抽出

この後、ワードカウント行うわけですが、形態素解析で得られた他の情報は不要なので、ここで単語のみのシーケンスにします。

;; morphological-analysis関数で得られたベクタの中から
;; 単語のみを抽出する
(defn token-only [tokenized-seq]
  (map #(% :token) tokenized-seq))

で、繋げて実行。

(->> (morphological-analysis (str "ClojureとLucene-Kuromojiを使って、"
                                  "テキストを形態素解析してワードカウントを行い、"
                                  "Clojureによるテキストマイニングにチャレンジしようと思います"))
     (select-nominal)
     (token-only)
     (println))

はい、単語のみになりました。

(clojure lucene kuromoji テキスト 形態素 解析 ワード カウント clojure テキスト マイニング チャレンジ)
ワードカウント

ここまでで得られた単語のシーケンスに対して、ワードカウントを行います。具体的には、単語をキーに出現回数を値にするマップを作成します。その関数定義。

;; 文字列のベクタを、単語と出現回数のマップに変換する
(defn word-count [token-seq]
  (reduce (fn [words word]
            (assoc words word (inc (get words word 0))))
          {}
          token-seq))

繋げます。

(->> (morphological-analysis (str "ClojureとLucene-Kuromojiを使って、"
                                  "テキストを形態素解析してワードカウントを行い、"
                                  "Clojureによるテキストマイニングにチャレンジしようと思います"))
     (select-nominal)
     (token-only)
     (word-count)
     (println))

結果。

{lucene 1, マイニング 1, clojure 2, kuromoji 1, 解析 1, チャレンジ 1, カウント 1, テキスト 2, 形態素 1, ワード 1}
ソート

得られたマップを、単語の出現回数の降順にソートしましょう。

;; word-count関数の結果を、出現回数の降順にソートする
(defn sort-desc-words [word-counted-map]
  (reverse (sort-by second word-counted-map)))

繋げます。

(->> (morphological-analysis (str "ClojureとLucene-Kuromojiを使って、"
                                  "テキストを形態素解析してワードカウントを行い、"
                                  "Clojureによるテキストマイニングにチャレンジしようと思います"))
     (select-nominal)
     (token-only)
     (word-count)
     (sort-desc-words)
     (println))

結果。

([テキスト 2] [clojure 2] [ワード 1] [形態素 1] [カウント 1] [チャレンジ 1] [解析 1] [kuromoji 1] [マイニング 1] [lucene 1])

ちょっと、それっぽくなってきましたね?

上位Nを抽出

これは、ここまでの結果にtake関数で先頭N個を取得すればよいです。

例えば、上位5件の場合は

(->> (morphological-analysis (str "ClojureとLucene-Kuromojiを使って、"
                                  "テキストを形態素解析してワードカウントを行い、"
                                  "Clojureによるテキストマイニングにチャレンジしようと思います"))
     (select-nominal)
     (token-only)
     (word-count)
     (sort-desc-words)
     (take 5)
     (println))

となります。

結果。

([テキスト 2] [clojure 2] [ワード 1] [形態素 1] [カウント 1])
グラフ表示

それでは、ここまでで得られた結果をIncanterを使ってグラフ表示してみましょう。

先ほどまで書いていた関数呼び出しの連鎖を、以下の様に変更します。

(let [wc (->> (morphological-analysis (str "ClojureとLucene-Kuromojiを使って、"
                                  "テキストを形態素解析してワードカウントを行い、"
                                  "Clojureによるテキストマイニングにチャレンジしようと思います"))
              (select-nominal)
              (token-only)
              (word-count)
              (sort-desc-words)
              (take 5))]
  (c/view (ch/bar-chart (keys wc) (vals wc))))

ちなみに、何もしないとプログラムが走り抜けてグラフもすぐに消えてしまうので、Enterを押すとプログラムが終了するようにしました。

;; 終了待ち
(println "Enterをすると終了します")
(read-line)

これで実行すると…

全然大したことありませんが、それっぽい結果が出ましたね!!

もう少し、大きなデータに対して実行する

以前と同様、坊ちゃんを入力としてデータ解析したいと思います。

坊っちゃん
http://www.aozora.gr.jp/cards/000148/files/752_14964.html

で、前回はこのテキストをファイルとしてダウンロードして実行していたのですが、今回はHTTPで取得するようにしましょう。

ここでは、Clojure Soupを使用してみます。

Clojure Soup
https://github.com/mfornos/clojure-soup

Clojure Soupを使用することで、以下のコードで先に紹介した坊ちゃんの本文を取得します(ルビが入ってますが…)。

(js/$ (js/get! "http://www.aozora.gr.jp/cards/000148/files/752_14964.html")
      "div.main_text"
      (js/text)
      (clojure.string/join "")

で、前回のエントリでは上位10と上位100を取得したので、今回もそれに習ってこんなコードにしました。

(let [^String text (js/$ (js/get! "http://www.aozora.gr.jp/cards/000148/files/752_14964.html")
                         "div.main_text"
                         (js/text)
                         (clojure.string/join ""))]
  (let [wc (->> (morphological-analysis text)
                (select-nominal)
                (token-only)
                (word-count)
                (sort-desc-words))
        top10 (take 10 wc)
        top100 (take 100 wc)]
    (c/view (ch/bar-chart (keys top10)
                          (vals top10)
                          :title "頻出単語 上位10位と出現数"
                          :x-label "単語"
                          :y-label "出現数"))
    (c/view (ch/bar-chart (keys top100)
                          (vals top100)
                          :title "頻出単語 上位100位と出現数"
                          :x-label "単語"
                          :y-label "出現数"))))

;; 終了待ち
(println "Enterをすると終了します")
(read-line)

結果、上位10件。

上位100件。

結果、前回と見た感じ変わらず(そりゃそうだ)。

今回作成したコードは、こちらにアップしています。
https://github.com/kazuhira-r/lucene-examples/blob/master/lucene-clojure-word-count/lucene-clojure-word-count.clj

前回と比べて、少しは見やすくなったと思うのですが、どうかなぁ…?

とはいえ、満足いってない部分もあって…

  • 形態素解析のところで、loop/recurを使ってしまった(repeatedlyでやろうとして、Analyzerをうまく動かせなかった)
  • Clojure Soupの呼び出し部を関数化すると、なぜかうまく動かなかった(セレクタ指定の部分を変数に入れると、なんか動きが変わりましたが…?)

というところが心残りではありますが、とりあえずこれくらいのものでしょうか。

このエントリで、ClojureやLucene、形態素解析などに興味を持たれる方がいらっしゃれば幸いです。
*統計解析は、自分が触ったばかりなので強く言えません…