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には実装されているのでそちらを紹介予定です。

gensimのTopicモデルでPerplexityを計算する

前回、scikit-learnのトピックモデル(LDA)における評価指標として、Perplexityを算出する方法を紹介しました。
参考: トピックモデルの評価指標Perplexityの実験

今回はgensim版です。
gensimのLDAモデルには log_perplexity と言うメソッドがあるので、それを使うだけです、って話であれば前の記事とまとめてしまってよかったのですが、
話はそう単純では無いので記事を分けました。

さて、 log_perplexity ってメソッドですが、いかにも perplexity の自然対数を返してくれそうなメソッドです。
perplexity が欲しかったら、 $\exp(log\_perplexity)$ を計算すれば良さそうに見えます。
しかし、 log_perplexity は perplexity の自然対数では無いと言う事実を確認できるのが次の実験です。

前回の記事と同じく、4つのトピックにそれぞれ5単語を含む架空の言語で実験します。


import numpy as np
from gensim.corpora.dictionary import Dictionary
from gensim.models import LdaModel


word_list = [
    ["white", "black", "red", "green", "blue"],
    ["dog", "cat", "fish", "bird", "rabbit"],
    ["apple", "banana", "lemon", "orange", "melon"],
    ["Japan", "America", "China", "England", "France"],
]
corpus_list = [
    np.random.choice(word_list[topic], 100)
    for topic in range(len(word_list)) for i in range(100)
]

# 単語と単語IDを対応させる辞書の作成
dictionary = Dictionary(corpus_list)
# LdaModelが読み込めるBoW形式に変換
corpus = [dictionary.doc2bow(text) for text in corpus_list]

# トピック数4を指定して学習
lda = LdaModel(corpus, num_topics=4, id2word=dictionary)

# log_perplexity を出力
print(lda.log_perplexity(corpus))
# -2.173078593289852

出力が $-2.17\dots$です。
正常に学習できていれば、Perplexityは約5になるはずなので、$\log(5)=1.609\dots$が期待されるのに、符号から違います。

ドキュメントをよく読んでみましょう。
log_perplexity

Calculate and return per-word likelihood bound, using a chunk of documents as evaluation corpus.
Also output the calculated statistics, including the perplexity=2^(-bound), to log at INFO level.

これによると、perplexityは$2^{-bound}$だと言うことになっていて、どうやら、
log_perplexity()が返しているのは、boundに相当するようです。

計算してみましょう。


print(2**(-lda.log_perplexity(corpus)))
# 4.509847333880428

正解は5なので、それらしい結果が出ています。
ですがしかし、Perplexityとしてはこの値は良すぎます。
今回のダミーデータで学習している限りは5単語未満に絞り込めるはずがないのです。

実際、モデルが学習した結果を見てみましょう。


print(lda.show_topics(num_words=6))
"""
[
    (0, '0.100*"bird" + 0.098*"dog" + 0.092*"melon" + 0.092*"cat" + 0.089*"orange" + 0.089*"rabbit"'), 
    (1, '0.104*"red" + 0.104*"green" + 0.102*"white" + 0.098*"blue" + 0.092*"black" + 0.084*"fish"'),
    (2, '0.136*"lemon" + 0.134*"apple" + 0.128*"banana" + 0.117*"orange" + 0.116*"melon" + 0.045*"China"'),
    (3, '0.216*"France" + 0.191*"America" + 0.181*"Japan" + 0.172*"England" + 0.163*"China" + 0.011*"apple"')
]
"""

本来は各トピック上位の5単語を0.2ずつの出現確率と予測できていないといけないので、今 Perplexity を計算しているモデルは
そんなに精度が良くないのです。(ハイパーパラメーターのチューニングを何もしてないので。それはそれで問題ですが、今回の議題からは外します。)

おかしいので、ソースコードを眺めてみたのですが、 2を底とする対数を取ってる様子は無く、普通に自然対数が使われていました。
なので、これはドキュメントの誤りとみた方が良さそうです。(将来的に修正されると思います。)

perplexity=e^(-bound)

と考えると、辻褄があいます。


print(np.exp(-lda.log_perplexity(corpus)))
# 8.785288789149925

トピック数を 1〜4 と動かして算出してみると明らかです。
トピック数が1の時は全く絞り込めていないので元の単語数の約20,
2の時は半分に絞れるので約10,
4の時は、ちゃんと学習できたら正解の5(ただし、デフォルトのハイパーパラメーターだとそこまで成功しないのでもう少し大きい値)
が算出されるはずです。

やってみます。


for i in range(1, 7):
    # トピック数を指定してモデルを学習
    lda = LdaModel(corpus, num_topics=i, id2word=dictionary)

    print(f"トピック数: {i}, Perplexity: {np.exp(-lda.log_perplexity(corpus))}")
"""
トピック数: 1, Perplexity: 20.032145913774283
トピック数: 2, Perplexity: 11.33724134037765
トピック数: 3, Perplexity: 8.921203895821304
トピック数: 4, Perplexity: 7.436279264160588
トピック数: 5, Perplexity: 7.558708610631221
トピック数: 6, Perplexity: 5.892976661122544
"""

大体想定通りの結果は出ましたね。

さて、 log_perplexity は perplexity の対数では無く、
perplexity 対数の符号を反転させたものである、と言うのは認識しておかないと大間違いの元になります。

というのも、perplexityは小さい方が良いとされる指標です。
と考えると、log_perplexityの出力が小さいパラメーターを選んでしまいがちですがそれは誤りであることがわかります。
対数を取ってから符号を反転させているので、大きいものを採用しないといけないのですね。

(この他、必ずしもPerplexityが小さければいいモデルだとは言えない、と言う議論もあるのですが、
今日の記事の範囲は超えますので省略します。)

トピックモデルの評価指標Perplexityの実験

このブログでトピックモデルの記事を書いたことがあるのですが、
トピック数の決め方について書いてないのに気づいたので評価指標を紹介します。

参考: pythonでトピックモデル(LDA)

トピックモデルのトピック数を決めるときは、Perplexityもしくは、Coherenceと呼ばれる指標を参考にします。
今回の記事では、Perplexityを紹介します。

と言っても、数学的な定義やその意味についてはいろんな場所で紹介されているので、
この記事では趣向を変えて、架空のデータで実験して理解を深めることを目指します。

まず、 Perplexity の定義は、各単語の出現確率(尤度)の逆数の幾何平均です。
(数式はいろんなサイトに乗っているので省略します。
書籍では、奥村学さんの「トピックモデルによる統計的潜在意味解析」などに載っています。)

この定義だけでは意味がわからないのですが、
「分岐数、または選択肢の数を表している」と説明されることが多いです。

例えば、ある文章があって、単語が一つ隠されていたとします。
このとき、LDAによって、その単語の選択肢が2000まで絞り込めていたら、
そのモデルの Perplexity は 2000です。
単語を絞り込めている方が優れたモデルとされるので、この値は低い方が良いモデルです。

まだわかりにくいので、ここから実験をしていきましょう。
次のような架空の世界があったとします。

– その世界の言葉には4個の話題(トピック)がある。
– 各話題ごとに、単語は5個ある。(つまりその世界に単語は20個しか無い)
– 各文章は一つの話題のみに言及する。

(これらの条件は正確にはトピックモデルではなく、混合ユニグラムモデルですが、わかりやすさのためご容赦ください。)

以上の設定のもとで、ランダムに100単語からなる文章をトピックごとに100個生成します。
コードを見ていただけるとわかりますが、4個の話題は色、動物、果物、国です。(なんでも良いのですが。)


import numpy as np

word_list = [
    ["white", "black", "red", "green", "blue"],
    ["dog", "cat", "fish", "bird", "rabbit"],
    ["apple", "banana", "lemon", "orange", "melon"],
    ["Japan", "America", "China", "England", "France"],
]
corpus = [
    " ".join(np.random.choice(word_list[topic], 100))
    for topic in range(len(word_list)) for i in range(100)
]

さて、あとは以前紹介したコードで、LDAモデルを作って、Perplexityを計算してみましょう。
scikit-learnの場合、ドキュメントにある通り、モデルがperplexityというメソッドを持っています。

トピック数はこの例では4が正解だとわかっているので、4を使います。

本当は、データを訓練データと評価データにわけて、評価データでperplexityを計算する必要があるのですが、
今回は実験なので訓練に使ったデータでそのまま評価します。


from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation

tf_vectorizer = CountVectorizer()
bow = tf_vectorizer.fit_transform(corpus)

# LDAのモデル作成と学習
lda = LatentDirichletAllocation(
    n_components=4,
)
lda.fit(bow)
# perplexityの計算
print(lda.perplexity(bow))
# 5.268629755256359

Perplexity は約 5.27 と、 5に近い値が出ましたね。
このLDAモデルで単語が5個くらいまで絞り込めていることがわかります。

Perplexity がトピック数の決定に使えることをみるために、他のトピック数でも計算してみましょう。


for c_num in range(1, 9):
    lda = LatentDirichletAllocation(
        n_components=c_num,
    )
    lda.fit(bow)
    print(f"トピック数: {c_num}, Perplexity: {lda.perplexity(bow)}")
"""
トピック数: 1, Perplexity: 20.033955224623902
トピック数: 2, Perplexity: 10.330848184515682
トピック数: 3, Perplexity: 7.397066706843117
トピック数: 4, Perplexity: 5.268629755256354
トピック数: 5, Perplexity: 5.305381334487885
トピック数: 6, Perplexity: 5.3074106945229875
トピック数: 7, Perplexity: 5.3206895866734305
トピック数: 8, Perplexity: 5.3529382429024315
"""

トピック数が1個の時は、全く絞り込めていないので、全単語数の20に近い値が出ています。
トピック数が2の場合は、半分に絞れているので約10ですね。
そして、トピック数が4の時に、大体5単語に絞れており、
それ以上トピック数を増やしても大きな改善はありません。
このことから、トピック数は4がベストだろうと判断することができます。

現実世界のデータで試すと、こんなに綺麗にトピック数を決めれたことが無く、
Perplexity の有効性に疑問を持っていたのですが、
理論的にはなかなか良い指標であることが確認できました。

gensimのモデルを保存するときのフォーマットを調べてみた

※ この記事を書いたときの僕の環境のgensimのバージョンは 3.8.0 です。

gensimでword2vecなり、トピックモデル(LDA)なり、そのための辞書なりを学習したとき、
saveメソッドで学習したモデルを保存し、同様にloadメソッドで読み込んで使うことができます。
このとき、ファイルに保存するので当然ファイル名を決めないといけないのですが、何形式で保存しているのかよくわからず拡張子に悩んでいました。

以前書いた、gensimでword2vec という記事のサンプルコードでは、ファイル形式がわからないのでとりあえず .model としています。
公式サイトの、Usage examples もそうなってるのですよ。

LDAの方は、
gensim.models.ldamodel.LdaModel.save
の Note を読むと、どうやら、pickleを使ってるように見えます。

ソースコードを確認してみましょう。
https://github.com/RaRe-Technologies/gensim/blob/master/gensim/models/ldamodel.py

def save(~略~):
のところを見ていくと、
salf.state ってのを使って、saveしていますね。

def __init__(~略~):
のところを確認すると、salf.state には、 LdaState というクラスのインスタンスが格納されており、
LdaStateは、
class LdaState(utils.SaveLoad):
とある通り、utils.SaveLoadを継承しています。
どうやらこれが保存と読み込みの本体のようです。

そのソースコードがこちらです。
https://github.com/RaRe-Technologies/gensim/blob/master/gensim/utils.py

このファイル内の、
class SaveLoad:
のところを見ていくと、普通にpickleを使ってファイルに書き出したり読み込んだ理されていることがわかりました。

LDAの次はWord2Vecです。
こちらは話が単純です。

ソースコードを見てみます。
https://github.com/RaRe-Technologies/gensim/blob/master/gensim/models/word2vec.py

class Word2Vec(utils.SaveLoad):

というふうに、モデル自体が、utils.SaveLoadを継承して作られており、先ほどのLDAと同様に保存と読み込みにはpickleが使われています。

Dictionary も同様です。
https://github.com/RaRe-Technologies/gensim/blob/master/gensim/corpora/dictionary.py

pickleは、このブログでも以前記事にしたことがあるようにPythonのオブジェクトを手軽にファイルに書き出せる形式です。
参考: pickleを使ってpythonのオブジェクトをファイルに保存する
ただ、これを使うとなると、いちいちwith openとかいろいろ書かないといけなくてややこしいので、
saveやloadなどわかりやすいメソッド名でラッピングしてもらえているのはありがたいですね。

さて、冒頭に挙げた問題の拡張子名ですが、.pickle あたりを使えば良さそうです。

gensimのDictionaryオブジェクトに含まれれる単語を出現頻度で絞り込む

最近久々にgensimのトピックモデルを使う機会がありました。
そのとき、出現する単語を頻度で絞り込みたったので方法を調べました。

トピックモデルの方法自体は、既に記事を書いてますのでこちらをご参照ください。
参考: gensimでトピックモデル(LDA)をやってみる

さて、gensimのLDAは、学習するコーパスを(単語, 出現回数) というタプルの配列に変換して読み込ませる必要があり、
その形への変換に、gensim.corpora.dictionary.Dictionaryを使います。
この辞書は、何も指定しないと、1回以上出現した単語を全部学習してしまいます。
それを、Scikit-learnのCountVectorizerで、min_dfを指定したときみたいに、n回以上出現した単語のみ、と足切りしたいというのが今回の記事の目的です。

Dictionaryの語彙学習時に指定できる引数の中に、CountVectorizerのmin_dfに相当するものがなかったので、てっきり指定できないのかと思っていたのですが、
じつは、学習した後に、語彙を絞り込む関数である、filter_extremesが用意されていることがわかりました。

使いかたを説明するために、まず適当な単語の羅列でコーパスを作って、辞書を学習しておきます。


import numpy as np
from gensim.corpora.dictionary import Dictionary

# 単語リスト作成
words = [
    "White",
    "Black",
    "Grey",
    "Red",
    "Orange",
    "Yellow",
    "Green",
    "Purple",
    "Blue",
    "Cyan",
    "Magenta",
]

# 再現性のためシードを固定する
np.random.seed(2)
# 単語を適当に選んで文章データを生成
documents = [
    np.random.choice(words, size=np.random.randint(3, 7)).tolist() for _ in range(10)
]

print(documents)
"""
[['Blue', 'Green', 'Grey'],
 ['Blue', 'Purple', 'Grey', 'Black', 'Yellow', 'Magenta'],
 ['Orange', 'Yellow', 'Purple'],
 ['Green', 'Orange', 'Magenta', 'Red', 'Purple', 'Green'],
 ['Black', 'Magenta', 'Red', 'Yellow', 'Blue'],
 ['Green', 'Red', 'Cyan'],
 ['Grey', 'White', 'Orange', 'Grey', 'Orange', 'Magenta'],
 ['Black', 'Purple', 'Blue', 'Grey', 'Magenta', 'Cyan'],
 ['Blue', 'Purple', 'Black', 'Green'],
 ['Magenta', 'Yellow', 'Cyan']]
"""

# 辞書の作成
dictionary = Dictionary(documents)

# 学習した単語リスト
for word, id_ in dictionary.token2id.items():
    print(f"id: {id_}, 単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}, 出現回数: {dictionary.cfs[id_]}")

"""
id: 0, 単語:  Blue,    出現ドキュメント数: 5, 出現回数: 5
id: 1, 単語:  Green,   出現ドキュメント数: 4, 出現回数: 5
id: 2, 単語:  Grey,    出現ドキュメント数: 4, 出現回数: 5
id: 3, 単語:  Black,   出現ドキュメント数: 4, 出現回数: 4
id: 4, 単語:  Magenta, 出現ドキュメント数: 6, 出現回数: 6
id: 5, 単語:  Purple,  出現ドキュメント数: 5, 出現回数: 5
id: 6, 単語:  Yellow,  出現ドキュメント数: 4, 出現回数: 4
id: 7, 単語:  Orange,  出現ドキュメント数: 3, 出現回数: 4
id: 8, 単語:  Red,     出現ドキュメント数: 3, 出現回数: 3
id: 9, 単語:  Cyan,    出現ドキュメント数: 3, 出現回数: 3
id: 10, 単語: White,   出現ドキュメント数: 1, 出現回数: 1
"""

これを4個以上の文章に登場した単語だけに絞りこみたいとすると、
filter_extremes(no_below=4)
を実行すれば良いよいうに思えます。
それでやってみたのがこちら。


dictionary.filter_extremes(no_below=4)
for word, id_ in dictionary.token2id.items():
    print(f"単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}")
"""
単語: Blue, 出現ドキュメント数: 5
単語: Green, 出現ドキュメント数: 4
単語: Grey, 出現ドキュメント数: 4
単語: Black, 出現ドキュメント数: 4
単語: Purple, 出現ドキュメント数: 5
単語: Yellow, 出現ドキュメント数: 4
"""

Orange/Red/Cyan/White が消えましたね。Orangeは出現回数自体は4でしたが、ドキュメント数が3だったので消えています。
ここで注意なのが、出現ドキュメント数が6だった、Magentaも消えていることです。

これは、filter_extremesのデフォルトの引数が、(no_below=5, no_above=0.5, keep_n=100000, keep_tokens=None) と、
no_above=0.5 も指定されていることに起因します。
つまり、全体の0.5=50%よりも多く出現している単語も一緒に消してしまうわけです。
逆に、no_above だけ指定しても、no_belowは5扱いなので、4文書以下にしか登場しない単語は足切りされます。

この挙動が困る場合は、忘れないように、no_belowとno_aboveを両方指定する必要があります。


# もう一度辞書の作成
dictionary = Dictionary(documents)
dictionary.filter_extremes(no_below=4, no_above=1)
for word, id_ in dictionary.token2id.items():
    print(f"単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}")
"""
単語: Blue, 出現ドキュメント数: 5
単語: Green, 出現ドキュメント数: 4
単語: Grey, 出現ドキュメント数: 4
単語: Black, 出現ドキュメント数: 4
単語: Magenta, 出現ドキュメント数: 6
単語: Purple, 出現ドキュメント数: 5
単語: Yellow, 出現ドキュメント数: 4
"""

出現回数で足切りするのではなく、残す単語数を指定したい場合は、keep_n を使えます。
(これにもデフォルト引数が入ってるので気をつけてください。元の単語数が100000を超えていたら、意図せず動作します)

5単語に絞り込むコードはこうなります。
単語は出現頻度が高い順に選ばれます。
no_below や no_aboveも同時に作用するので、これらの設定次第では、keep_nで指定したよりも少ない単語しか残らないことがあります。


# もう一度辞書の作成
dictionary = Dictionary(documents)
dictionary.filter_extremes(no_below=1, no_above=1, keep_n=5)
for word, id_ in dictionary.token2id.items():
    print(f"単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}")
"""
単語: Blue, 出現ドキュメント数: 5
単語: Green, 出現ドキュメント数: 4
単語: Grey, 出現ドキュメント数: 4
単語: Magenta, 出現ドキュメント数: 6
単語: Purple, 出現ドキュメント数: 5
"""

あとは、あまり使わなさそうですが、 keep_tokens に単語を指定することで、no_belowや、no_aboveに関係なく、
その単語を残すことができます。


# もう一度辞書の作成
dictionary = Dictionary(documents)
dictionary.filter_extremes(no_below=5, no_above=1, keep_tokens=["White"])
for word, id_ in dictionary.token2id.items():
    print(f"単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}")
"""
単語: Blue, 出現ドキュメント数: 5
単語: Magenta, 出現ドキュメント数: 6
単語: Purple, 出現ドキュメント数: 5
単語: White, 出現ドキュメント数: 1
"""

小ネタですが、Dictionaryオブジェクトは、各単語が出現したドキュメント数をdfs, 出現した回数をcfsという変数に保有しています。
filter_extremes を実行すると、dfsの方は単語が絞り込まれた上でidも振り直されるのですが、
cfsは単語が絞り込まれるだけで、idが振り直されません。
(なぜこんな仕様になっているのかは謎です。将来的に修正されるような気がします。)
直前のサンプルコードを動かした時点で、 dfsとcfs の中身を見たものがこちらです。
単語数が4個に減っているのは共通ですが、cfsの方はidが5とか10とか、元のままであることがわかります。


print(dictionary.dfs)
# {0: 5, 2: 5, 1: 6, 3: 1}
print(dictionary.cfs)
# {0: 5, 5: 5, 4: 6, 10: 1}

scikit-learnで多項式回帰する方法

前回の記事で書いたのがNumPyで多項式回帰する方法だったので、今回はscikit-learnで行う方法を紹介します。
参考: NumPyで多項式回帰

NumPyの方法は、1変数の多項式に特化していたので、変数が1個しかない場合は非常に手軽に使えました。
ただ、実際は $x_0$ の多項式に加えて $x_1$, $x_2$ の変数も使って回帰したいとか、
$x_0, x_1$ を組み合わせた $x_0 * x_1$ みたいな項も入れたいとかいろんなケースがあると思います。
そのような場合は、scikit-learnの利用が検討できます。

と言っても、scikit-learnに多項式関数のモデルが実装されているわけではなく、
実際は前処理だけやってくれるモデルと、通常の線形回帰のモデルを組み合わせて使うことになります。
(このめんどくささが、1変数ならNumPyを推す所以です。)

多項式の特徴量生成には、 sklearn.preprocessing.PolynomialFeatures を使います。

試しに、3変数のデータ4セットに対して、2次までの項を生成してみたコードが次です。


import numpy as np
from sklearn.preprocessing import PolynomialFeatures

X = np.arange(12).reshape(4, 3)
print(X)
"""
[[ 0  1  2]
 [ 3  4  5]
 [ 6  7  8]
 [ 9 10 11]]
"""
poly = PolynomialFeatures(2)  # 次数2を指定
X_poly = poly.fit_transform(X)
print(X_poly)
"""
[[  1.   0.   1.   2.   0.   0.   0.   1.   2.   4.]
 [  1.   3.   4.   5.   9.  12.  15.  16.  20.  25.]
 [  1.   6.   7.   8.  36.  42.  48.  49.  56.  64.]
 [  1.   9.  10.  11.  81.  90.  99. 100. 110. 121.]]
 """

元のデータを$x_0, x_1, x_2$ とすると、
定数$1$,$x_0, x_1, x_2, x_0^2, x_0x_1, x_0x_2, x_1^2, x_1x_2, x_2^2$ のデータが生成されているのがわかります。

何番目のデータがどういう演算で生成された項なのか、という情報は、powers_ という属性に保有されています。


print(poly.powers_)
"""
[[0 0 0]
 [1 0 0]
 [0 1 0]
 [0 0 1]
 [2 0 0]
 [1 1 0]
 [1 0 1]
 [0 2 0]
 [0 1 1]
 [0 0 2]]
"""

定数1の項はいらないな、という時は、 include_biasにFalseを指定して、
PolynomialFeatures(2, include_bias=False)とすれば出てきません。

あとは、この生成されたデータを使って回帰分析を行えば、 scikit-learn を用いた多項式回帰の完成です。

scikit-learnの学習済み決定木モデルから学習結果を抽出する

scikit-learnで学習した決定木の学習結果を確認するにはライブラリを使うのが便利ですが、
自分でも直接取得してみたかったので方法を調べてみました。

参考:
dtreevizで決定木の可視化
graphvizで決定木を可視化

とりあえず、 iris を学習しておきます。dtreevizの記事とパラメーターを揃えたので、
この後の結果はそちらと見比べていただくとわかりやすいです。
ただし、最初の分岐が2パターンあって乱数でどちらになるか決まるので、運が悪いと結果が変わります。


from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier

iris = load_iris()
clf = DecisionTreeClassifier(min_samples_split=5)
clf.fit(
    iris.data,
    iris.target
)

"""
DecisionTreeClassifier(ccp_alpha=0.0, class_weight=None, criterion='gini',
                       max_depth=None, max_features=None, max_leaf_nodes=None,
                       min_impurity_decrease=0.0, min_impurity_split=None,
                       min_samples_leaf=1, min_samples_split=5,
                       min_weight_fraction_leaf=0.0, presort='deprecated',
                       random_state=None, splitter='best')
"""

ロジスティック回帰などであれば、係数が coef_に入っているだけなので簡単なのですが、
決定木の場合読み解くのに少し手間がかかります。

その辺りのことは、ドキュメントにも
Understanding the decision tree structureとしてまとめてあるのでこちらも参照しながら読み解いてみました。

必要な情報は clf.tree_の属性としてまとまっているので順番に取り出してみます。


# ノードの数
n_nodes = clf.tree_.node_count
print(n_nodes)
# 13

# 各ノードに振り分けられた学習データの数。
node_values = clf.tree_.value

# 各ノードの左の子ノード。 葉の場合は -1
children_left = clf.tree_.children_left
print(children_left)
# [ 1 -1  3  4  5 -1 -1  8 -1 -1 11 -1 -1]

# 各ノードの右の子ノード。 葉の場合は -1
children_right = clf.tree_.children_right
print(children_right)
# [ 2 -1 10  7  6 -1 -1  9 -1 -1 12 -1 -1]

# 分割に使う特徴量。 葉の場合は-2
feature = clf.tree_.feature
print(feature)
# [ 3 -2  3  2  3 -2 -2  3 -2 -2  2 -2 -2]

# 分割に使う閾値。 葉の場合は-2
threshold = clf.tree_.threshold
print(threshold)
"""
[ 0.80000001 -2.          1.75        4.95000005  1.65000004 -2.
 -2.          1.55000001 -2.         -2.          4.85000014 -2.
 -2.        ]
"""

要するに、各ノードが配列の要素に対応しており、
それぞれ配列に、左の子ノード、右の子ノード、分割に使う特徴量、分割に使う閾値が順番に入っています。

これらの情報を日本語に変化して表示すると次の様になるでしょうか。


for i in range(n_nodes):
    print("\nノード番号:", i)
    if children_left[i] == -1:
        print("    このノードは葉です。")
        print("        予測結果: ")
        for v, t in zip(node_values[i][0], iris.target_names):
            print("            "+t+": ", round(v/sum(node_values[i][0]), 3))
    else:
        print(
            "    "+iris.feature_names[feature[i]],
            "が",
            round(threshold[i], 3),
            "未満の場合、ノード:",
            children_left[i],
            "に進み、それ以外の場合は、",
            children_right[i],
            "に進む。"
        )

出力結果のテキストはこちらです。


ノード番号: 0
    petal width (cm) が 0.8 未満の場合、ノード: 1 に進み、それ以外の場合は、 2 に進む。

ノード番号: 1
    このノードは葉です。
        予測結果: 
            setosa:  1.0
            versicolor:  0.0
            virginica:  0.0

ノード番号: 2
    petal width (cm) が 1.75 未満の場合、ノード: 3 に進み、それ以外の場合は、 10 に進む。

ノード番号: 3
    petal length (cm) が 4.95 未満の場合、ノード: 4 に進み、それ以外の場合は、 7 に進む。

ノード番号: 4
    petal width (cm) が 1.65 未満の場合、ノード: 5 に進み、それ以外の場合は、 6 に進む。

ノード番号: 5
    このノードは葉です。
        予測結果: 
            setosa:  0.0
            versicolor:  1.0
            virginica:  0.0

ノード番号: 6
    このノードは葉です。
        予測結果: 
            setosa:  0.0
            versicolor:  0.0
            virginica:  1.0

ノード番号: 7
    petal width (cm) が 1.55 未満の場合、ノード: 8 に進み、それ以外の場合は、 9 に進む。

ノード番号: 8
    このノードは葉です。
        予測結果: 
            setosa:  0.0
            versicolor:  0.0
            virginica:  1.0

ノード番号: 9
    このノードは葉です。
        予測結果: 
            setosa:  0.0
            versicolor:  0.667
            virginica:  0.333

ノード番号: 10
    petal length (cm) が 4.85 未満の場合、ノード: 11 に進み、それ以外の場合は、 12 に進む。

ノード番号: 11
    このノードは葉です。
        予測結果: 
            setosa:  0.0
            versicolor:  0.333
            virginica:  0.667

ノード番号: 12
    このノードは葉です。
        予測結果: 
            setosa:  0.0
            versicolor:  0.0
            virginica:  1.0

先日可視化した結果とバッチリ対応していますね。

dtreevizで特徴量とラベルの関係を可視化

※この記事では dtreevizの version 0.8.2 を使っています。

前回の記事では、dtreeviz を使って学習済みの決定木を可視化しました。
dtreevizではこの他にも、1個か2個の特徴量とラベルの関係を可視化できます。

それが、 ctreeviz_univar と、ctreeviz_bivar です。
扱える特徴量がuniの方が1個、biの方が2個です。

データは必要なので、irisを読み込んでおきます。今回は木は不要です。
(その代わり、max_depsかmin_samples_leafのどちらかの設定が必須です。)


from sklearn.datasets import load_iris
iris = load_iris()

まず1個のほうをやってみます。
特徴量4個しかないので全部出します。


import matplotlib.pyplot as plt
from dtreeviz.trees import ctreeviz_univar

figure = plt.figure(figsize=(13, 7), facecolor="w")
for i in range(4):
    ax = figure.add_subplot(2, 2, i+1)
    ctreeviz_univar(
        ax,
        iris.data[:, i],
        iris.target,
        max_depth=2,
        feature_name=iris.feature_names[i],
        class_names=iris.target_names.tolist(),
        target_name='types'
    )

plt.tight_layout()
plt.show()

どの特徴量が有効なのか、自分的にはこれまでで一番わかりやすいと感じました。

次は2個の方です。特徴量2種類とラベルを渡すと、それらの関係を可視化してくれます。
2個ずつ選んで2つのグラフで可視化してみました。
引数、ですがfeature_name が feature_names になっており、渡す値も文字列が配列になっているので注意が必要です。


from dtreeviz.trees import ctreeviz_bivar

figure = plt.figure(figsize=(5, 12), facecolor="w")
ax = figure.add_subplot(2, 1, 1)
ctreeviz_bivar(
    ax,
    iris.data[:, :2],
    iris.target,
    max_depth=2,
    feature_names=iris.feature_names[:2],
    class_names=iris.target_names.tolist(),
    target_name='types'
)

ax = figure.add_subplot(2, 1, 2)
ctreeviz_bivar(
    ax,
    iris.data[:, 2:],
    iris.target,
    max_depth=2,
    feature_names=iris.feature_names[2:],
    class_names=iris.target_names.tolist(),
    target_name='types'
)

plt.show()

出力がこちら。

これもわかりやすいですね。

dtreevizで決定木の可視化

早速、前回の記事でインストールした dtreeviz を使ってみます。

※この記事では dtreevizの version 0.8.2 を使っています。
1.0.0 では一部引数の名前などが違う様です。(X_train が x_dataになるなど。)

とりあえず、データと可視化する木がないと話にならないので、いつものirisで作っておきます。


from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier

iris = load_iris()
clf = DecisionTreeClassifier(min_samples_split=5)
clf.fit(
    iris.data,
    iris.target
)

さて、これで学習したモデル(コード中のclf)を可視化します。
リポジトリのコードを見ながらやってみます。

まず、一番シンプルな可視化は、 dtreeviz.trees.dtreevizにモデルと必要なデータを全部渡すものの様です。
(省略不可能な引数だけ設定して実行しましたが、結構多いですね。)


from dtreeviz.trees import dtreeviz

tree_viz = dtreeviz(
    tree_model=clf,
    X_train=iris.data,
    y_train=iris.target,
    feature_names=iris.feature_names,
    target_name="types",
    class_names=iris. target_names.tolist(),
)
tree_viz

出力がこちら。

graphvizで決定木を可視化 でやったのと比べて、とてもスタイリッシュで解釈しやすいですね。

orientation(デフォルトは’TD’)に’LR’を指定すると、向きを縦から横に変更できます。


tree_viz = dtreeviz(
    tree_model=clf,
    X_train=iris.data,
    y_train=iris.target,
    feature_names=iris.feature_names,
    target_name="types",
    class_names=iris. target_names.tolist(),
    orientation='LR',
)
tree_viz

出力がこちら。

木のサイズによってはこれも選択肢に入りそうですね。