pyLDAvisを用いたトピックモデル(LDA)の可視化 (scikit-learn編)

久々にトピックモデルの記事です。
トピックモデルを実行した時、各トピックが結局何の話題で構成されているのかを確認するのは結構面倒です。
また、各トピック間の関係などもなかなか掴みづらく解釈しにくいところがあります。
この問題が完全に解決するわけではないのですが、
pyLDAvisと言うライブラリを使うと、各トピックを構築する単語や、トピック間の関係性を手軽に可視化することができます。
しかもインタラクティブな可視化になっており、ポチポチ動かせるので眺めてみるだけで楽しいです。

まずは、可視化するモデルがないことには始まらないので、いつものライブドアニュースコーパスでモデルを学習しておきます。
トピック数はいつもより大目に20としました。(雑ですみません。いつも通り、前処理も適当です。)


import pandas as pd
import MeCab
import re
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation

# データの読みこみ
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化
df["text"] = df["text"].str.normalize("NFKC")
# アルファベットを小文字に統一
df["text"] = df["text"].str.lower()

# 分かち書きの中で使うオブジェクト生成
tagger = MeCab.Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")
# ひらがなのみの文字列にマッチする正規表現
kana_re = re.compile("^[ぁ-ゖ]+$")


def mecab_tokenizer(text):
    # テキストを分かち書きする関数を準備する
    parsed_lines = tagger.parse(text).split("\n")[:-2]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 品詞を取得
    pos = [f.split(',')[0] for f in features]

    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]

    # 名詞,動詞,形容詞のみに絞り込み
    target_pos = ["名詞", "動詞", "形容詞"]
    token_list = [t for t, p in zip(token_list, pos) if p in target_pos]
    # アルファベットを小文字に統一
    token_list = [t.lower() for t in token_list]
    # ひらがなのみの単語を除く
    token_list = [t for t in token_list if not kana_re.match(t)]
    # 数値を含む単語も除く
    token_list = [t for t in token_list if not re.match("\d", t)]
    return " ".join(token_list)


# 分かち書きしたデータを作成する
df["text_tokens"] = df.text.apply(mecab_tokenizer)

print(df["text_tokens"][:5])
"""
0    読売新聞 連載 直木賞 作家 角田光代 初 長編 サスペンス 八日目の蝉 檀れい 北乃きい ...
1    アンテナ 張る 生活 映画 おかえり、はやぶさ 公開 文部科学省 タイアップ 千代田区立神田...
2    全国ロードショー スティーブン・スピルバーグ 待望 監督 最新作 戦火の馬 アカデミー賞 有...
3    女優 香里奈 都内 行う 映画 ガール 公開 女子高生 限定 試写 会 サプライズ 出席 女...
4    東京都千代田区 内幸町 ホール 映画 キャプテン・アメリカ/ザ・ファースト・アベンジャー 公...
Name: text_tokens, dtype: object
"""

# テキストデータをBOW形式に変換する
tf_vectorizer = CountVectorizer(
    token_pattern='(?u)\\b\\w+\\b',
    max_df=0.90,
    min_df=10,
)
tf = tf_vectorizer.fit_transform(df["text_tokens"])

# LDAのモデル作成と学習
lda = LatentDirichletAllocation(n_components=20)
lda.fit(tf)

さて、この後が本番です。
pyLDAvisのドキュメントはこちらですが、
scikit-learnのLDAの可視化の方法はこっちのnotebookを参照すると良いです。
Jupyter Notebook Viewer pyLDAvis.sklearn

pyLDAvis.sklearn.prepareに、LDAのモデル、BOW(かtf-idf)の学習に用いたデータ、変換に用いた辞書を順番に渡すだけです。
jupyter notebook 上で結果を表示するためにpyLDAvis.enable_notebook()というメソッドも呼び出しています。


import pyLDAvis
import pyLDAvis.sklearn

# jupyter notebookで結果を表示するために必要
pyLDAvis.enable_notebook()

pyLDAvis.sklearn.prepare(
    lda, # LDAのモデル (LatentDirichletAllocation のインスタンス)
    tf, # BOWデータ (sparse matrix)
    tf_vectorizer, # CountVectorizer もしくは TfIdfVectorizer のインスタンス
)

実行すると、以下のような図が表示されます。blogに貼ったのは画像ですが、実際は左の散布図で注目するトピックを切り替えたり、
右側の単語一覧から単語を選択することで、各トピックにその単語がどれだけ含まれるかみることができます。

次のように、 prepare メソッドの戻り値を受け取っておいて、 pyLDAvis.save_htmlメソッドに渡すことで、htmlとして保存することもできます。
このブログにも貼っておくので、是非触ってみてください。

HTMLファイルへのリンク: ldavis-sample

7番目と19番目や、5番目と13番目のトピックが大きくかぶっていて、トピック数が少し多すぎたかなとか、そう言った情報も得られますし、
単語の一覧から各トピックが何の話題なのかざっくりとつかめますね。

表示する単語数は、 R(デフォルト30)と言う引数で指定できます。
また、左側のトピックの位置関係表示に使う次元削減方法は、mdsと言う引数で変更もできます。
指定できるのは現在のところ、pcoa, mmds, tsne の3種類です。

pytablewriterで、pandasのDataFrameからMarkdownの表を生成する

前回の記事に続いて、pytablewriterの機能の紹介です。
参考: pytablewriterでMarkdownの表を生成する

今回はpandasのDataFrameからMarkdownを生成します。
個人的には使う頻度が高いのはこちらです。

まずサンプルとなるデータを作っておきましょう。


import pandas as pd

df = pd.DataFrame(
    data=[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
    columns=['col1', 'col2', 'col3'],
    index=["data1", "data2", "data3", "data4"]
)
print(df)
"""
       col1  col2  col3
data1     1     2     3
data2     4     5     6
data3     7     8     9
data4    10    11    12
"""

早速、このDataFrameをMarkdownに変更します。

利用するのは、 from_dataframe と言うメソッドです。
ドキュメント: 4.4.3. Using pandas DataFrame as tabular data source

次のコードの例のように、 from_dataframe() にDataFrameを渡し、
write_table()メソッドを呼び出すと標準出力にマークダウンが出力されます。


from pytablewriter import MarkdownTableWriter


writer = MarkdownTableWriter()
writer.from_dataframe(df)
writer.write_table()
"""
|col1|col2|col3|
|---:|---:|---:|
|   1|   2|   3|
|   4|   5|   6|
|   7|   8|   9|
|  10|  11|  12|
"""

配列から生成するときはメソッドではなく属性にデータを代入していたのでちょっと使い方が違いますね。

さて、結果をご覧の通り、デフォルトではindexは出力されません。
もしindexが必要な場合は、 from_dataframe()を呼び出す時に、 add_index_column=True を指定する必要があります。


writer = MarkdownTableWriter()
writer.from_dataframe(df, add_index_column=True)
writer.write_table()
"""
|     |col1|col2|col3|
|-----|---:|---:|---:|
|data1|   1|   2|   3|
|data2|   4|   5|   6|
|data3|   7|   8|   9|
|data4|  10|  11|  12|
"""

これでindexも出力されました。

pytablewriterでMarkdownの表を生成する

集計結果を社内に共有する時など、Markdownでテーブルを書く機会はそこそこ頻繁にあります。
(実はBacklogを使っているので、正式なMarkdownとは違うのですが似たようなものです。)

毎回、Vimで置換等を使って書いて、それを貼り付けたりとかしているのですが、
Pythonに pytablewriter という便利なライブラリがあるのを見つけたのでそれを紹介します。
自分の場合は pandas の DataFrameを変換することが多く、それ専用の関数もあるのですが、
まずはこの記事ではドキュメントの Basic usage に沿った基本的な使い方を紹介します。

ドキュメント: 4.1. Basic usage — pytablewriter

ドキュメントの例を参考に、少し書き換えたコードでやってみます。


from pytablewriter import MarkdownTableWriter

writer = MarkdownTableWriter()
writer.table_name = "zone"
writer.headers = ["zone_id", "country_code", "zone_name"]
writer.value_matrix = [
    ["1", "AD", "Europe/Andorra"],
    ["2", "AE", "Asia/Dubai"],
    ["3", "AF", "Asia/Kabul"],
    ["4", "AG", "America/Antigua"],
    ["5", "AI", "America/Anguilla"],
]

writer.write_table()
"""
# zone
|zone_id|country_code|   zone_name    |
|------:|------------|----------------|
|      1|AD          |Europe/Andorra  |
|      2|AE          |Asia/Dubai      |
|      3|AF          |Asia/Kabul      |
|      4|AG          |America/Antigua |
|      5|AI          |America/Anguilla|
"""

テーブル名、ヘッダー、データを順番に指定してあげて、
その後、write_tableを呼び出すとマークダウンのテキストを生成してくれますね。

カラム名は指定しないと、A,B,C,Dとアルファベットが割り当てられるようです。


writer = MarkdownTableWriter()
writer.value_matrix = [
    ["1", "AD", "Europe/Andorra"],
    ["2", "AE", "Asia/Dubai"],
    ["3", "AF", "Asia/Kabul"],
    ["4", "AG", "America/Antigua"],
    ["5", "AI", "America/Anguilla"],
]
writer.write_table()
"""
| A | B |       C        |
|--:|---|----------------|
|  1|AD |Europe/Andorra  |
|  2|AE |Asia/Dubai      |
|  3|AF |Asia/Kabul      |
|  4|AG |America/Antigua |
|  5|AI |America/Anguilla|
"""

引数で渡すのではなく、.value_matrixなどの変数にデータを渡すのが特徴的で、
ちょっと使い方に癖がありますが手軽にマークダウンを生成できるので便利ですね。

n進法(特に2進法,8進法,16進法)の文字列を10進法の数値に変換する

以前書いた記事の逆向きの操作が必要になったのでそのメモです。
参考: Pythonの数値を2進法、8進法、16進法の表記に変換する

結論から言うと intメソッドでできます。
そのまま数字として解釈できる文字列(要するに0から9と+-)しか受け付けないと思い込んでいたのですが、
ドキュメントを読んでみると、2つ目の引数baseで、基数を指定できます。
ここに8なり16なりを渡してあげれば良いようです。


print(int("FF", 16))
# 255

print(int("100", 8))
# 64

base は 0と2~36までの引数を指定できます。(1はダメ)
36進法は 0~9までの数値と、a~z(A~Zでも可)の文字を使った表記ですね。


print(int("AZ09", 36))
# 511929

base=0 を指定した場合、文字列の先頭のプレフィックスを使って、2進法,8進法,10進法,16進法を判定するようです。


# 2進法
print(int("0b100", 0))
# 4

# 8進法
print(int("0o100", 0))
# 64

# 10進法
print(int("100", 0))
# 100

# 16進法
print(int("0x100", 0))
# 256

PythonでROT13暗号

業務で使う機会があったので紹介します。

非常に単純な暗号方式のに、シーザー暗号と呼ばれるものがあります。
参考: シーザー暗号 – Wikipedia

暗号鍵として整数nを決め、アルファベットをn文字ずらしたものに変換するものです。
$n=3$ であれば、aはdに、GはJに、Zはまた先頭に戻ってCに変換されます。
複合はその逆です。

この暗号鍵を13にしたものをROT13と呼びます。
参考: ROT13 – Wikipedia
アルファベットが大文字小文字それぞれ26文字であり、そのちょうど半分をスライドさせることから、
2回適用すると元に戻ると言う特徴があります。要するに暗号化と複合が全く同じと言う特徴があります。

これはPythonでは、 codecs という標準ライブラリの中に実装されています。
参考: codecs — codec レジストリと基底クラス — Python 3.9.1 ドキュメント

早速やってみます。


import codecs


text = "Hello World"
print(codecs.encode(text, "rot_13"))
# Uryyb Jbeyq

# もう一度適応すると元に戻る
print(codecs.encode("Uryyb Jbeyq", "rot_13"))
# Hello World

# encode と decodeの結果が等しい
print(codecs.decode(text, "rot_13"))
# Uryyb Jbeyq

簡単ですね。

コード中の “rot_13” ですが、 “rot13″というアンダーバー無しのエイリアスも用意されています。
そのため、以下のコードでも動きます。


print(codecs.encode(text, "rot13"))
# Uryyb Jbeyq

このROT13ですが、アルファベット以外の文字にには作用しません。数字や日本語の文字などはそのまま残ります。


print(codecs.decode("ROT13暗号はアルファベット以外そのまま", "rot13"))
# EBG13暗号はアルファベット以外そのまま

2021年のご挨拶と今年の方針

新年明けましておめでとうございます。本年もよろしくお願いします。

年末年始の間、このブログの目標をどうしようかなと考えていたのですが、
一旦は昨年と同じペースを維持することを目指したいと思います。
と言うことで、今年も年間100記事の更新を目指します。
随分前からネタ切れ感もあるのですが、
100記事の目標があればネタ探しを兼ねたインプットにも力を入れられると思います。

気合を入れて書いた記事よりもちょっとした小ネタのような記事の方がニーズがあることもはっきりしてきたことですし、
何か調べたら何か書く、くらいのテンションで今年もやっていきたです。

また、技術記事以外の記事も増やしたい(と昨年も言ってたのに結局書かなかった)ので、
今年こそポエム記事も書いていきたいと思います。

2020年のまとめ

今年も一年間お世話になりました。
多くの方に訪問いただけていたのでモチベーションを失わずにブログ更新を続けることができました。

2020年最後の投稿になりますので、今年1年間の振り返りをしたいと思います。

まず基本的なデータ。記事数はトータル、残り二つは2020年1年間の実績です。

– 記事数 409記事 (この記事含む)
– 訪問ユーザー数 146,674人
– ページビュー 258,698回

2019年と比べて、どの数値も大きく伸びていることがわかります。
更新頻度こそ2019年から落としていますが、更新を続けることによって(結果的に記事にしなかった内容も含めて)多くの学びを得ることができた1年間でした。

さて、恒例のよく読まれた記事ランキングをみていきましょう。
今回は2020年1年間でのPV数によるランキングです。

  1. matplotlibのグラフを高解像度で保存する
  2. macにgraphvizをインストールする
  3. kerasのto_categoricalを使ってみる
  4. numpyのpercentile関数の仕様を確認する
  5. scipyで階層的クラスタリング
  6. DataFrameを特定の列の値によって分割する
  7. INSERT文でWITH句を使う
  8. matplotlibのデフォルトのフォントを変更する
  9. pythonで累積和
  10. Pythonで多変量正規分布に従う乱数を生成する

あれ、今年書いた記事があんまりランクインしてないような。
基本的な内容の記事がニーズが高い傾向にはあるようですね。

僕自身の備忘録的な記事も多く、これは本当に訪問される皆さんの役に立ってるのかと心配になることも多いのですが、
アクセスを見る限りではちゃんとニーズがあったようで嬉しいです。
無理して内容のレベルを上げるよりも今の調子での更新が良いのかもしれませんね。

さて、年初に立てた目標の方も振り返ってみると、更新回数以外の目標はさっぱりでした。
参考: 2020年のご挨拶と今年の目標
Kaggleはそのうち挑戦しようと思いつつ全然やってないし、
技術記事以外の記事もさっぱり書いていません。
技術記事だけで100記事書けたと言うことでもあるので、それが悪いわけではないのですが当初思ってたのとは方向がずれたかなと思います。

来年どのくらい記事を書くかとか、その内容とかはブログ以外の目標やプランともすり合わせて計画を立てて、
年初の記事で方針発表させていただけたらと思います。

今年も1年間ありがとうございました。良いお年を。

シンプソンのパラドックス

先日、データを分析している中でシンプソンのパラドックスが発生しているのを見かけました。
興味深い現象なので、紹介したいと思います。
ただし、業務的な情報は書けないので記事中の用語も設定もデータも全部架空の物です。

2種類のアプリがあったとします。それぞれ旧アプリと、新アプリとします。
そしてそれらのアプリを使っているユーザーがとある属性によってグループA,グループBに分かれていたとします。

ユーザー数の内訳が次のようになっていたとします。(単位:人)

旧アプリ 新アプリ
グループA 40000 1000
グループB 60000 9000

これらのユーザーのコンバージョン数が次の通りだったとします。

旧アプリ 新アプリ
グループA 3200 100
グループB 1800 360

コンバージョン率を見ると次のようになりますね。

旧アプリ 新アプリ
グループA 8% 10%
グループB 3% 4%

どちらのグループのユーザーに対しても、新アプリの方がコンバージョン率が高いことがわかりました。

しかしここで、グループごとに分けて集計することをやめて、新アプリと旧アプリを単純に比較してみます。

旧アプリ 新アプリ
ユーザー数 100000 10000
コンバージョン数 5000 460
コンバージョン率 5% 4.6%

なんと、新アプリより旧アプリの方がコンバージョン率が高いことになりました。

このように、
集団全体を複数の集団に分けてそれぞれの集団で同じ仮説(今回の例では新アプリの方がコンバージョン率が高い)が成り立っても、
集団全体に対してはそれが成り立たないことがあることをシンプソンのパラドックスと呼びます。

これは$\frac{a}{A}>\frac{b}{B}$ かつ $\frac{c}{C}>\frac{d}{D}$ が成り立ったとしても
$$\frac{a+c}{A+C}>\frac{b+d}{B+D}$$
が成り立つわけじゃないと言う単純な数学的な事実から発生する物です。

今回の例で言えば、新アプリの方がどちらのユーザー群に対しても良い効果をもたらしているので良さそうなのに、
全体の集計だけで旧アプリの方が良いと結論づけてしまうと誤った分析をしてしまうことになります。
注意する必要がありますね。

gensimでCoherence(UMass)の算出

前回に続いてCoherence(UMass)の話です。
今回は実際にプログラムで算出してみます。

Perplexityの時は、架空の言語で実験していましたが、あのデータではCoherenceを試すのには都合が悪いことが多いので、
久しぶりに 20newsgroups のデータを使います。

とりあえず読み込んで単語区切りの配列にしておきます。
簡単な前処理として小文字に統一し、アルファベット以外の文字は除去しておきましょう。


from sklearn.datasets import fetch_20newsgroups
import re

# データ読み込み
twenty = fetch_20newsgroups(
    subset="all",
    remove=('headers', 'footers', 'quotes')
)
texts = twenty.data

# 簡単な前処理
# 小文字に統一
texts = [text.lower() for text in texts]
# アルファベット以外は除去
texts = [re.sub("[^a-z]+", " ", text).strip() for text in texts]
# 空白で区切り配列に変換
texts = [text.split(" ") for text in texts]

モデルを学習しておきます。


from gensim.corpora.dictionary import Dictionary
from gensim.models import LdaModel


# 単語と単語IDを対応させる辞書の作成
dictionary = Dictionary(texts)
# 出現が20回未満の単語と、50%より多くのドキュメントに含まれる単語は除く
dictionary.filter_extremes(no_below=20, no_above=0.5)
# LdaModelが読み込めるBoW形式に変換
corpus = [dictionary.doc2bow(text) for text in texts]

# トピック数 20 でモデル作成
lda = LdaModel(corpus, num_topics=20, id2word=dictionary)

# 学習結果
lda.show_topics(num_topics=20, num_words=5)
"""
[(0, '0.017*"was" + 0.016*"were" + 0.015*"they" + 0.010*"on" + 0.009*"with"'),
 (1, '0.025*"you" + 0.018*"be" + 0.016*"this" + 0.013*"on" + 0.011*"if"'),
 (2, '0.031*"key" + 0.014*"chip" + 0.013*"s" + 0.013*"be" + 0.013*"this"'),
 (3, '0.019*"he" + 0.016*"s" + 0.014*"game" + 0.013*"team" + 0.012*"was"'),
 (4, '0.154*"ax" + 0.067*"m" + 0.036*"p" + 0.034*"q" + 0.032*"f"'),
 (5, '0.018*"not" + 0.014*"you" + 0.013*"as" + 0.013*"are" + 0.013*"be"'),
 (6, '0.014*"with" + 0.014*"this" + 0.013*"have" + 0.012*"windows" + 0.011*"or"'),
 (7, '0.019*"have" + 0.016*"my" + 0.013*"if" + 0.013*"with" + 0.012*"this"'),
 (8, '0.016*"s" + 0.015*"as" + 0.012*"you" + 0.012*"be" + 0.012*"are"'),
 (9, '0.016*"x" + 0.014*"on" + 0.011*"or" + 0.009*"are" + 0.009*"with"'),
 (10, '0.017*"with" + 0.017*"scsi" + 0.015*"card" + 0.015*"mb" + 0.013*"x"'),
 (11, '0.016*"echo" + 0.014*"surface" + 0.013*"planet" + 0.012*"launch" + 0.011*"moon"'),
 (12, '0.045*"x" + 0.013*"section" + 0.011*"s" + 0.011*"file" + 0.010*"if"'),
 (13, '0.025*"image" + 0.017*"you" + 0.014*"or" + 0.011*"file" + 0.011*"can"'),
 (14, '0.015*"on" + 0.013*"s" + 0.011*"by" + 0.011*"be" + 0.009*"from"'),
 (15, '0.031*"you" + 0.020*"t" + 0.020*"they" + 0.014*"have" + 0.013*"s"'),
 (16, '0.024*"edu" + 0.013*"by" + 0.012*"from" + 0.012*"com" + 0.008*"university"'),
 (17, '0.049*"he" + 0.044*"was" + 0.029*"she" + 0.019*"her" + 0.013*"s"'),
 (18, '0.013*"are" + 0.011*"with" + 0.010*"or" + 0.010*"car" + 0.010*"s"'),
 (19, '0.020*"power" + 0.019*"myers" + 0.014*"g" + 0.014*"e" + 0.012*"period"')]
"""

ストップワードの除去などの前処理をもっと真面目にやった方が良さそうな結果になってますね。。。

一旦今回の目的の Coherence の計算に進みます。
実はこの学習したldaのオブジェクト自体も算出用のメソッドを持っているのですが、
それとは別にCoherenceの計算専用のクラスがあります。

参考: models.coherencemodel
これを使うのが簡単でしょう。


from gensim.models.coherencemodel import CoherenceModel 

# Coherenceの計算
cm = CoherenceModel(
    model=lda,
    corpus=corpus,
    dictionary=dictionary,
    coherence='u_mass', # Coherenceの算出方法を指定。 (デフォルトは'c_v')
    topn=20 # 各トピックの上位何単語から算出するか指定(デフォルト20)
)
print(cm.get_coherence())
# -1.661056661022431

簡単に得られましたね。

前の記事で UMass Coherence はトピックごとに求まると言う話をしました。
それは、get_coherence_per_topic()で得られます。


coherence_per_topic = cm.get_coherence_per_topic()
print(coherence_per_topic)
"""
[
-0.6804875178873847,
-0.7418651773889635,
-2.112586843668905,
-1.5566020659262867,
-1.3794139986539538,
-0.9397322672431955,
-0.9144876198536442,
-1.050640800753007,
-0.791666801060629,
-1.5573334717678569,
-1.7592326101494569,
-2.4339787874196244,
-3.4187325854772057,
-1.4492302603021243,
-0.8756627315871455,
-0.8056235761203832,
-2.121420273613335,
-3.5341207908402237,
-1.1732696265877514,
-3.925045414147548
]
"""

# 平均を計算 (cm.get_coherence()と一致する)
print(sum(coherence_per_topic)/len(coherence_per_topic))
# -1.661056661022431

配列は0番目のトピックから順番にそれぞれのCoherenceを示します。

この他、ldaオブジェクトが持っている、 top_topics() というメソッドでもCoherenceを得られます。

Get the topics with the highest coherence score the coherence for each topic.

というドキュメントの説明通り、本来は、coherenceが高いtopicsを求めるためのものなので注意が必要です。
(高い順にソートされてしまうこととか、topnの引数が小さいと途中で打ち切られることとか。)

何故かこれは coherenceを算出する方法のデフォルトが’u_mass’です。(CoherenceModelは’c_v’なのに。)

このような感じで使います。


lda.top_topics(corpus=corpus, coherence='u_mass', topn=20)

"""
[([(0.017169138, 'was'),
   (0.015623925, 'were'),
   (0.015399945, 'they'),
   (0.010244668, 'on'),
   (0.00926054, 'with'),
   (0.009065351, 'had'),
   (0.0085669095, 'their'),
   (0.008559594, 'by'),
   (0.00840216, 's'),
   (0.008026317, 'at'),
   (0.007591937, 'from'),
   (0.007375864, 'as'),
   (0.0072524482, 'are'),
   (0.007035575, 'have'),
   (0.00656652, 'all'),
   (0.0062510483, 'or'),
   (0.0058769537, 'who'),
   (0.005784945, 'this'),
   (0.0057791225, 'there'),
   (0.0056513455, 'but')],
  -0.6804875178873847),
# 長いので2トピック目以降の結果は略
]
"""

トピックごとのタプルの配列が戻り、タプルの2番目の要素がcoherenceです。
少し扱いにくいですね。

Coherence(UMass)によるトピックモデルの評価

今回もトピックモデルの評価指標の話です。
前の2記事でPerplexityを扱ったので、今回は Coherence を扱います。

さて、トピックモデルの予測精度を評価していたPerplexityに対して、
Coherenceはトピックの品質を評価するものです。

人間が見て、このトピックは何の話題に言及するものだ、とわかりやすく分類できていたらCoherenceが高くなります。
そう説明すると単純なように思えるのですが、これを実現する指標を作るのはなかなか大変です。
そのため、Coherenceの定義はこれ、と明確に定まったものは無く、いろんな手法が提案されています。
gensimに定義されているものだけでも、u_mass, c_v, c_uci, c_npmi と4つがありますし、
実際に提唱されているものは人間が評価するものなどもっとあります。

これらの中で、別のコーパス(Wikipediaのデータなど)を用意しなくてよかったり、Google検索結果などを使わなくても良く、
計算速度も早い u_mass の使い方を紹介します。

提唱された論文はこちらです。
参考: Optimizing Semantic Coherence in Topic Models

どうでもいいのですが、 u_mass が何の略なのかずっと疑問でした。
論文を見ると University of Massachusetts のようですね。
Mimno(2011)のDavid Mimnoさんは Princeton University なのでなぜu_massと呼ばれているのかは謎です。

話を戻します。
論文中で提唱されているCoherenceは次の式で計算できます。

トピック$t$に対して、出現頻度の高い$M$個の単語の集合を$V^{(t)}=\{v_1^{(t)},\dots,v_M^{(t)}\}$とします。
$D(v)$を単語の出現文書数、$D(v_1,v_2)$を単語の共起文書数とするとトピック$t$のCoherenceは次の式になります。
$$
C(t; V^{(t)})=\sum_{m=2}^{M}\sum_{l=1}^{m-1}\log\frac{D(v_m^{(t)}, v_l^{(t)})+1}{D(v_l^{(t)})}.
$$

要するに、そのトピックの頻出単語たちがよく共起してるほど高くなるように作られています。
そのため、トピック数等の決定に使う場合は、Coherenceが高いものを採用します。
(Perplexityは低いものを採用するので注意です。)

定義から明らかですが、この指標は各トピックごとに計算されます。
そのため、モデルの評価として使うには各トピックごとのCoherenceの平均を使います。

前置きが長くなってきたので、サンプルコードは次の記事で書きたいと思います。
なお、 scikit-learnの方には実装がないようです。
そのため、scikit-learnでLDAを実装した場合は上の式を自分で実装する必要があります。

gensimには実装されているのでそちらを紹介予定です。