話題になっているトピックを抽出 - LDA with Spark MLlib


LDA (Latent Dirichlet Allocation) は機械学習により大量のテキストデータから何がトピック (話題) となっているかを推測します。より具体的には文書集合内の単語の出現頻度 (Bag of Words) を特徴ベクトルとしてクラスタリングを行い、クラスタごとに中心に近い単語を抽出します。

例えば最近トレンドになっているキーワード (Twitter のトレンドのようなもの) や、サポートメールに含まれる単語から最近多い問い合わせのネタを抽出するようなケースで利用できます。

機能

Spark MLlib 1.6.0 の LDA では以下の機能が実装されています。

  1. 文書集合に含まれている単語を k 個のトピック (クラスタ) に分類。
  2. あるトピックに含まれている (トピックを特徴付けている) 単語を重み付けし上位を抽出: LDAModel#discribeTopics()
  3. あるトピックに含まれている (トピックの特徴が強く出ている) 文書を重み付けし上位を抽出: DistributedLDAModel#topDocumentPerTopic()
  4. ある文書に含まれているトピックをクラスタリングし抽出: DistributedLDAModel#topTopicsPerDocument()
  5. 計算結果のモデルの保存と復元。

まだ experimental のためか、新規の文書に対してトピック所属確率を推定したり加算的にモデルを更新することはできないようです。モデルの評価 (perplexity) については不明。

実装アルゴリズムと分散について

Spark MLlib 1.6.0 時点で実装されているアルゴリズムは EMLDAOptimizer (デフォルト) と OnlineLDAOptimizer です。EM (Expectation-Maximization; 期待値最大化法) は内部でグラフに落とし込んで GraphX で分散処理しています。Online Variational Bayes (オンライン変分ベイズ法) の方は分散処理に対応していません。より精度が高く分散も可能な Collapsed Gibbs Sampling の実装は以前に言及がありましたが音沙汰がないようです。必要であれば独自の LDAOptimizer を実装して利用することができます。

注意したいのが、LDA の計算コストよりもノード間で特徴ベクトルを転送するコストの方が高いようで、想定しているデータ量では分散処理を行うよりもコア数の多い単一ノードで処理する方が速いという結論になりがちです。処理速度を気にする場合は分散と非分散 (local[16] など) で試してみてください。

サンプル実行

処理の流れ

例として1行が1文書に対応するテキストファイル samples.txt に対して以下の処理を行います。

1.文書データを読み込んで形態素解析。
2.特徴を表しやすい品詞の単語のみを抽出。
3.出現した全ての単語からコーパスを作成。
4.文書ごとの単語頻出を作成。
5.LDAでトピックを抽出。

実行

個別に build.sbt を既述するのも面倒なので bashsbtsbt console と一発コピペ実行。sbt が実行できる環境だけあらかじめ用意してください。

$ sbt << 'EOF'
set scalaVersion := "2.11.7"
set libraryDependencies += "org.apache.spark" %% "spark-mllib" % "1.5.1"
set libraryDependencies += "com.atilika.kuromoji" % "kuromoji-ipadic" % "0.9.0"
console

import java.io.File
import scala.collection.JavaConversions._
import org.apache.spark.{SparkConf,SparkContext}
import org.apache.spark.mllib.clustering.{LDA,DistributedLDAModel}
import org.apache.spark.mllib.feature.{HashingTF,IDF}
import org.apache.spark.mllib.linalg.Vectors
import org.apache.spark.rdd.RDD
import com.atilika.kuromoji.ipadic.{Token,Tokenizer}

val conf = new SparkConf().setAppName("LDASample").setMaster("local[2]")
val sc = new SparkContext(conf)

// 文書をロードして形態素解析 (1行1文書で保存されているものとする)
val file = new File("samples.txt")
val pos2 = Set("代名詞", "数", "非自立", "特殊", "接尾", "接続詞的", "動詞非自立的")
val docs = sc.textFile(file.toURI.toURL.toString).map{ t =>
  val tokenizer = new Tokenizer()
  tokenizer.tokenize(t).filter{ tk =>
    tk.getPartOfSpeechLevel1 == "名詞" && ! pos2.contains(tk.getPartOfSpeechLevel2) && tk.getBaseForm != "*"
  }.map{ _.getBaseForm }
}.cache()

// コーパス作成: 単語⇄INDEX 相互変換マップ
val term2Id = docs.flatMap{ identity }.distinct.zipWithIndex.map{ case (term, index) => (term, index.toInt) }.collectAsMap
val id2Term = term2Id.map{ case (term, index) => (index, term) }.toMap

// 文書ごとに単語の出現頻度を特徴ベクトルとして作成
val termCounts = docs.zipWithIndex.map{ case (terms, docId) =>
  val counts = terms.groupBy{ identity }.mapValues{ _.size }.map{ case (term, count) =>
    (term2Id(term).toInt, count.toDouble)
  }
  (docId, Vectors.sparse(term2Id.size, counts.toSeq))
}

// トピックに該当する3つのクラスタを抽出
val lda = new LDA().setK(3).setMaxIterations(50)
val ldaModel = lda.run(termCounts)
ldaModel.describeTopics(maxTermsPerTopic = 10).foreach{ case (terms, termWeights) =>
  println("******************")
  terms.zip(termWeights).foreach{ case (term, weight) =>
    println(f"${id2Term(term.toInt)}%s\t$weight%.3f")
  }
}

sc.stop()
:quit
exit
EOF

結果

手元の 5,000 文書を使って k=3 (3トピックに分類)、iteration=50 で実行してみたところ以下のような結果になりました。一つ目は携帯やスマフォの利用に関する話題、二つ目はパソコンの利用や購入に関する話題、三つ目は職場での恋愛に関する話題といったところでしょうか。広く集めたので漠然とした単語が抽出されていますが、文書の投稿先やカテゴリ等などで抽出するとより具体的なトピックになると思います。

******************
お願い  0.024
質問    0.011
場合    0.011
月      0.009
メール  0.008
回答    0.007
サイト  0.006
電話    0.005
可能    0.005
表示    0.005
******************
質問    0.016
お願い  0.013
使用    0.011
方法    0.011
補足    0.010
パソコン        0.009
問題    0.008
表示    0.008
設定    0.007
製品    0.007
******************
人      0.025
自分    0.021
今      0.013
仕事    0.012
前      0.010
好き    0.010
女性    0.009
会社    0.009
男性    0.008
気      0.008

k を大きくすると処理に時間がかかります。既にある程度話題が絞られている文書 (例えば海外旅行に関する記事など) に対しては k を小さめに、話題が絞られていない文書に対しては k を大きめにすると良い傾向にあります。上記は文書に含まれる話題が広範囲だったため k=100 程度に増やした方がより特徴的なトピックに分類されたでしょうね(・ω・) 

また繰り返し数も低すぎると(人の感覚的な)精度が落ちますし増やすと時間がかかります。50~200 程度を目安にトレードオフの調整をすれば良いかと思います。

なおこの文書規模であれば LDA の計算は数十秒~数百秒程度ですが、形態素解析の方で2時間程度かかっています。コーパス化したデータは累積的に保存できますので再計算のコストはそれほど大きくありません。