logo logo

June 02, 2019 13:46

LDA実践編:20newsgroupsでサクッとトピックモデリング

この記事の目的

  • 最近ブログ記事がたまってきたんだけど、結構いろんなトピックで書いてるな。
  • どうせなら似たような記事をユーザーにリコメンドできないかな。
  • 自分はウェブサイト運営してるんだけど、ユーザーの投稿をいい感じにクラスタリングして分類してみたい、その分類の中ではどんなキーワードがホットなんだろうか?

このような要望に応える技術にはどんなものがあるんでしょうか?今回は書類の中からトピックと呼ばれる潜在的意味抽出を行うトピックモデルの代表的手法であるLDA(潜在的ディリクレ配分法:Latent Dirichlet Allocation)を紹介してみたいと思います。今回はLDAって聞いたことあるけど、実際どんな感じで使えんの?あるいは理論面とか興味ないけど、手っ取り早く上のようなやつやってみたいという方向けにざくざくPythonコード書いて試してっていう実践/実装的なところをまとめていこうと思います。理論面でも機械学習のエッセンスを要所に含んでいて大変教育的なので、近いうちにまとめるつもりです。

扱うデータセットとゴール

今回は文書分類タスクで標準的な20newsgroupsという20カテゴリの英文書群データセットを扱います。
日本語の文書分類ではライブドアニュースのデータセットが有名かもしれません。

  • ライブドアニュースをLDAで扱った記事は結構ある
  • 日本語だと分かち書きをする必要があり、それ自体は大した処理ではないものの、必要なライブラリの用意が面倒だったりするので、英語を扱うことにした

という理由です。目標としては

  • このデータセットをLDAでクラスタリングしてみて、クラスタごとの典型的な単語の解析をしてみる
  • どうやって適切なクラスタ数をみつけるべきか、代表手法とともに実験してみる
  • Word Cloudを使って、結果を格好よく可視化してみる

あたりのことをやってみます。元々20クラスは分かっているので、上手くいっているかの評価も幾分しやすいでしょう。

データ前処理

データのインポートとデータの様子見

LDAをここで用いるgensimモジュールで適用するには次の入力データを用意する必要があります。

  • データセットの単語別出現回数ないし頻度をまとめたコーパス(bag of words)
  • データセットに出現する全単語をインデックス化した辞書

まずはこれを目標にしていきます。なにはともあれ必要なモジュールをインポートしましょう。本解析にとって必要だったモジュールをまとめているので、まだ個人の環境にインストールが済んでいなければ先に行っていただくか、必要になったときに用意してみてください。

from sklearn.datasets import fetch_20newsgroups
from pprint import pprint
import gensim
from gensim import corpora, models
from wordcloud import WordCloud
from PIL import Image
import matplotlib.pyplot as plt
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
import nltk
from nltk.stem.porter import PorterStemmer
from nltk.stem.wordnet import WordNetLemmatizer

scikitlearnモジュールを通して20newsgroupsのデータセットをインポートします。
一応訓練データとテストデータを分けていますが、本解析では訓練データだけを使うことにします。

newsgroups_train = fetch_20newsgroups(subset='train', shuffle = True)
newsgroups_test = fetch_20newsgroups(subset='test', shuffle = True)

どんなグループに分かれているのか見てみましょう。

pprint(list(newsgroups_train.target_names))

すると、次のような出力が得られるはずです。

  • 'alt.atheism',
  • 'comp.graphics',
  • 'comp.os.ms-windows.misc',
  • 'comp.sys.ibm.pc.hardware',
  • 'comp.sys.mac.hardware',
  • 'comp.windows.x',
  • 'misc.forsale',
  • 'rec.autos',
  • 'rec.motorcycles',
  • 'rec.sport.baseball',
  • 'rec.sport.hockey',
  • 'sci.crypt',
  • 'sci.electronics',
  • 'sci.med',
  • 'sci.space',
  • 'soc.religion.christian',
  • 'talk.politics.guns',
  • 'talk.politics.mideast',
  • 'talk.politics.misc',
  • 'talk.religion.misc'

コンピューター、余興、科学、雑談が大きなカテゴリーを占めており、そのサブカテゴリーと宗教、商売系のトピックで20カテゴリーを形成しているようです。またどんなサンプルなのかも確認してみましょう。

print(newsgroups_train.data[0])

From: lerxst@wam.umd.edu (where's my thing)
Subject: WHAT car is this!?
Nntp-Posting-Host: rac3.wam.umd.edu
Organization: University of Maryland, College Park
Lines: 15
I was wondering if anyone out there could enlighten me on this car I saw
the other day. It was a 2-door sports car, looked to be from the late 60s/
early 70s. It was called a Bricklin. The doors were really small. In addition,
the front bumper was separate from the rest of the body. This is
all I know. If anyone can tellme a model name, engine specs, years
of production, where this car is made, history, or whatever info you
have on this funky looking car, please e-mail.
Thanks,
- IL
---- brought to you by your neighborhood Lerxst ----

なるほど、メール形式のデータのようです。またlen(newsgroups_train.data)などと入力すると、11314件のサンプル数だとわかります。機械学習としてはまぁまぁのデータ量といったところでしょうか。

データ成形

次にLDAの入力を意識しつつのデータ成形を行います。
入力ための成形とはいえ、LDAではサンプルで偏りなく頻繁に出てくる単語については、トピックにかかわらず頻出単語として拾ってしまうため、意味のない単語を極力落とすのが、よい結果を得るためには重要です。また似たような単語は極力まとめてしまうほうが処理速度の上でも結果の品質の上でもよい効果を与えます。語幹抽出 (ステミング)、見出し語化 (レマタイズ)といった処理も加えて、品質の向上を図ることにします。

def lemmatize_stemming(text):
    stemmer = PorterStemmer()
    return stemmer.stem(WordNetLemmatizer().lemmatize(text, pos='v'))
texts = []
# Tokenize and lemmatize
def preprocess(text, original_stopwords):
    result=[]
    for token in gensim.utils.simple_preprocess(text):
        if token not in gensim.parsing.preprocessing.STOPWORDS and len(token) > 3:
            if token not in original_stopwords:
                result.append(lemmatize_stemming(token))
    return result

preprocess関数では、gensim.utils.simplepreprocessにて、生のテキストデータ(単語続きの文章データ)を単語トークンに区切り、gensimに初期登録されているストップワード(英語自然処理の観点から、文法上は意味があっても、文章の特徴づけとしては意味の少ない単語、例えば冠詞のa, theや、主語としてどこにでもでてくるI, you, we, theyなど)と、3文字以上の単語を省いて結果のトークンリストに加えています。lemmatizestemming関数では、単語の見出し語化と語幹抽出を行い、単語の広がりを抑えています。
WordNetLemmatizerが使えない場合は予めnltk.download('wordnet')を実行してwordnetの情報をダウンロードしておきます。

processed_docs = []
original_stopwords=["lines", "subject", "write", "organization", "wrote", "written", "writes", "writing",
                        "writings", "university", "writer", "nntp-posting-host", "posting", "post", "posted", "posts", "nntp"]
docs = newsgroups_train.data

for doc in docs:
    processed = preprocess(doc, original_stopwords)
    processed_docs.append(processed)

# 処理後のドキュメント群で辞書オブジェクトを作る。
dictionary = gensim.corpora.Dictionary(processed_docs)
# 出現頻度のn回以下は除外、出現率x以上も除外
dictionary.filter_extremes(no_below=100, no_above=0.1)
bow_corpus = [dictionary.doc2bow(doc) for doc in processed_docs]

関数の定義が終了次第、個別の文章に適用します。処理後のドキュメント群で辞書をつくり、同時にBag od Words形式でコーパスを作ります。ストップワードは結果を見ながら調節しましたが、ここで頑張るよりもまずは辞書にフィルターを適用して余分な単語を除く処理を行ってから検討するとよいでしょう。ここでは100回未満のレアな単語と、1割以上の文章で出現する頻出単語をフィルタリングしています。この辺りのパラメータは劇的に結果を左右しますが、LDAの品質は精度やら残差で定量化できるものではないので、使用者自身が結果の様子を見ながらチューニングすることが基本的に求められます。

LDAの適用

まずは元のクラスタ数を参考にして、トピック数を20として実験してみましょう。
LDAでは適切なトピック数は使用者である人間が探索しながら、用途に沿うように調節する必要があります。

topic_K = 20
lda_model =  gensim.models.LdaMulticore(bow_corpus, 
                                   num_topics = topic_K, 
                                   id2word = dictionary)

引数のオプションは公式ドキュメントを参考にするのがよいでしょう。並列計算で処理してくれるのも魅力的です。前処理さえ終わってしまえばこのコードで自動的にLDAの学習を行ってくれます。
それでは各トピックに頻繁に出現する単語を眺めてみましょう。

for i in range(topic_K):
    print("\n")
    print("="*80)
    print("TOPIC {0}\n".format(i+1))
    topic = lda_model.show_topic(i, 30)
    for t in topic:
        print("{0:20s}{1}".format(t[0], t[1]))

例えばトピック1には以下のような単語が頻繁に出現するようです。

file, engin, version, card, base, exist, group, public , program, softwar, chang, littl, build, sell, run, make, high, standard, idea, robert

これにどんなラベルを付けるべきかというのは改めてLDAの責任ではなく、使用者がぐっとにらんで解釈を与える必要があります。program, softwareなどという言葉から、その前に出てくるengineやversion, cardといった単語もソフトウェア関連の文脈で出てきている可能性が高いと推測されます。一方でsellやhighなどといった形容詞は、今の用途ではあまり情報としては有益でないので、品詞解析をしたうえで前処理で省いてみてもよいかもしれないといった示唆を与えてくれます。
ここでは全部を表示することはしませんが、全体を通して宗教系の単語、christianや科学系の文脈で頻繁に出てくるnasaといった単語が複数のトピックにまたがって出現しており、全般的にそれぞれのトピックの特徴はきれいに分かれているとはいえない印象です。

PerplexityとCoherenceから適切なトピック数を探索

ここでは理論的な詳細は省略しますが、LDAの適切なトピック数の策定によく用いられる指標がPerprexotyとCoherenceという指標です。最新の研究でもよりよい指標について色々な議論もされていますが、標準実装されているこれらの指標をもとに、良さそうなトピック数に当たりをつけてみます。正確性を犠牲にすることを恐れずに言えばPerplexityは与えられたコーパスにモデルがどの程度適応しているかの指標で、小さいほうがコーパスとのフィットがよいと考えます。Coherenceはやはり大まかに言って分類後のトピックと、その出現単語がどの程度見分けのつく品質になっているかを定量化したもので、こちらは大きいほどよい指標になります。注意したいのはPerplexityについては数学的に厳密に定量化できるメリットはあるものの、$K$について単調な変化をしてしまう(つまり最適な選択が難しい)例や、人間の評価との差異(cf. Ldaのモデル選択におけるperplexityの評価)が報告されており、あまりあてにしすぎないことが肝要です。(自分の感覚と一致するようなときはそれを示す証左の一つ程度には考えてもよいかも)Coherenceのほうが、個人的な感覚としてはよい印象を与える傾向があるように思いますが、いずれにせよ二つを組み合わせて良さそうな$K$にあたりをつけ、その前後で目視調査しながらよい値を探索するなどのアプローチをとるのが無難な気がします。(難しいからこそ、今でも研究テーマになりうる)

start = 2
limit = 30
step = 2

coherence_vals = []
perplexity_vals = []

for n_topic in range(start, limit, step):

    lda_model = gensim.models.ldamodel.LdaModel(corpus=bow_corpus, id2word=dictionary, num_topics=n_topic, random_state=0)
    perplexity_vals.append(np.exp2(-lda_model.log_perplexity(bow_corpus)))
    coherence_model_lda = gensim.models.CoherenceModel(model=lda_model, texts=processed_docs, dictionary=dictionary, coherence='c_v')
    coherence_vals.append(coherence_model_lda.get_coherence())

fig, ax1 = plt.subplots(figsize=(12,5))
x = range(start, limit, step)

c1 = 'gray'
ax1.plot(x, coherence_vals, 'o-', color=c1)
ax1.set_xlabel('Num Topics')
ax1.set_ylabel('Coherence', color=c1); ax1.tick_params('y', colors=c1)

c2 = 'green'
ax2 = ax1.twinx()
ax2.plot(x, perplexity_vals, 'o-', color=c2)
ax2.set_ylabel('Perplexity', color=c2); ax2.tick_params('y', colors=c2)

ax1.set_xticks(x)
fig.tight_layout()
plt.grid()
plt.show()

これの出力結果が次のようなグラフになりました。

description for this picture

なるほど、結果を見せる前にごちゃごちゃとエクスキューズを並べたてましたがこれを見る限りはPerplexityが小さく、Coherenceも大きい$K = 12$とするのがよさそうです。早速結果を眺めてみましょう。おんなじ方法で結果を見せるのは記事としては芸がないので、ここではWordCloudを作ってみます。

topic_K = 12
lda_model =  gensim.models.LdaMulticore(bow_corpus, 
                                   num_topics = topic_K, 
                                   id2word = dictionary,                                    
                                   passes = 10,
                                   workers = 4)

fig, axs = plt.subplots(ncols=4, nrows=int(lda_model.num_topics/4), figsize=(20,15))
axs = axs.flatten()

for i, t in enumerate(range(lda_model.num_topics)): 
    x = dict(lda_model.show_topic(t, 30))
    im = WordCloud(
        width=800, height=460,
        background_color='white',
        random_state=0
    ).generate_from_frequencies(x)
    axs[i].imshow(im)
    axs[i].axis('off')
    axs[i].set_title('Topic '+str(t+1))

plt.tight_layout()
plt.show()

description for this picture

こうして俯瞰的に眺めると、例えば左上のトピック1はコンピュータ関連(20newsgroupsでも1大カテゴリー)、トピック3はスポーツ(元のクラスタ分けのホッケーと野球がまとまって一つになっている)、トピック5は銃火器(元のトピックのtalk.politics.gunsを色濃く反映している?)、トピック6はセキュリティ関連、トピック12はキリスト教など、一目にわかりやすくトピックとして潜在的な意味を捉えられている印象を得られました。それでも似たようなトピックがあったりもしますので、まだチューニングなり前処理探索の余地がありそうです。個人的には辞書コーパスに用いる品詞の限定は是非とも検討したいと思います。(いっそのこと名詞だけにしてみてもよいかも)

参考