TfidfVectorizerのidfについて

この記事では、scikit-learnのTfidfVectorizerの、idf部分について掘り下げてみていきます。
以前の記事でも書いた通り、デフォルトでのidfの挙動は一般的な定義とは異なります。
参考: tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

単語$t$についてみていくと、通常の定義は
$$
\text{idf}_{t} = \log{\frac{\text{総文書数}}{\text{単語tを含む文書数}}}
$$
であり、
TfidfVectorizer のデフォルトのオプションでは、
$$
\text{idf}_{t} = \log{\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}}+1
$$
となっています。

まず、実際にこの数式通り動いていることを見ておきましょう。


import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer

# データを準備しておく
corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
)
tfidf_model.fit(corpus)

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)

"""
and 1.916290731874155
document 1.2231435513142097
first 1.5108256237659907
is 1.0
one 1.916290731874155
second 1.916290731874155
the 1.0
third 1.916290731874155
this 1.0
"""

# 出現回数から計算したidfの値
for i in range(1, 5):
    print(f"出現回数{i}回, idf: ", np.log((1+4)/(1+i))+1)
"""
出現回数1回, idf:  1.916290731874155
出現回数2回, idf:  1.5108256237659907
出現回数3回, idf:  1.2231435513142097
出現回数4回, idf:  1.0
"""

document は3文書に登場しているから、idfは 1.2231435513142097、firtstは2文書に登場しているから、idfは1.5108256237659907と言うふうに、
scikit-learnのドキュメント通りに計算されていることがわかりますね。

さて、scikit-learnの定義がデフォルトと異なっている理由は、次の2つがあります。
(1) コーパス中に登場しない単語で0除算が発生しないように、log中の分数の分子と分母に1を足す。
(2) コーパス中の全ての文書に登場した単語のidfが0にならないように全ての単語のidfに1を足す。

(1)の方は平滑化と呼ばれる操作です。
実はTfidfVectorizerは、学習する単語をコーパスから自動的にピックアップするのではなく、モデルの引数として渡すことができます。
次のコード例では、コーパスに登場しない、oovという単語を明示的に加えてみました。
結果、$\log((1+4)/(1+0))+1 = 2.6094379124341005$ がoovのidf値になっていることがわかります。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    vocabulary=['and', 'document', 'first', 'is',
                'one', 'second', 'the', 'third', 'this', "oov"],
)
tfidf_model.fit(corpus)

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)
"""
and 1.916290731874155
document 1.2231435513142097
first 1.5108256237659907
is 1.0
one 1.916290731874155
second 1.916290731874155
the 1.0
third 1.916290731874155
this 1.0
oov 2.6094379124341005
"""

この分子と分母の+1については、smooth_idfと言う引数にFalseを渡すことで使わないようにもできます。
参考: sklearn.feature_extraction.text.TfidfVectorizer

この場合、vocabulary引数で、コーパスに登場しない単語が渡されていると、0除算のワーニングが発生し、該当単語のidf値はinfになります。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    vocabulary=['and', 'document', 'first', 'is',
                'one', 'second', 'the', 'third', 'this', "oov"],
    smooth_idf=False,
)
tfidf_model.fit(corpus)
"""
RuntimeWarning: divide by zero encountered in true_divide
  idf = np.log(n_samples / df) + 1
"""

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)
"""
and 2.386294361119891
document 1.2876820724517808
first 1.6931471805599454
is 1.0
one 2.386294361119891
second 2.386294361119891
the 1.0
third 2.386294361119891
this 1.0
oov inf
"""

tfidfの本来の定義に近い値で使う場合は、smooth_idf=Falseを指定するべきですが、この場合は安全のため、vocabularyは指定せず、
コーパスから自動的に学習するのに任せるべきでしょう。
(僕の場合は、vocabulary引数を使うことはほとんどありません。)

次に、(2)の全てのコーパスに倒叙する単語のidfが0にならないように、全部単語のidfに1足されている部分についてです。
こちらについては、これをオフにする引数のようなものは実装されていなさそうです。

どうしてもtfidfの本来の定義で使いたいんだ、と言う場合は、かなり無理矢理な操作になりますが、
モデルが学習したidfの値(idf_プロパティに格納されている)から1を引いてしまう手があります。
(当然、サポートされている操作ではないので実行は自己責任でお願いします。)

transform する際には、idf_ の値が使われるので次のようになります。


import pandas as pd
# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    smooth_idf=False,
)
tfidf_model.fit(corpus)
tfidf = tfidf_model.transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     1.288  1.693  1.0  0.000   0.000  1.0  0.000   1.0
1  0.000     2.575  0.000  1.0  0.000   2.386  1.0  0.000   1.0
2  2.386     0.000  0.000  1.0  2.386   0.000  1.0  2.386   1.0
3  0.000     1.288  1.693  1.0  0.000   0.000  1.0  0.000   1.0
"""

# idf_ に足されている1を引く 
tfidf_model.idf_ -= 1
tfidf = tfidf_model.transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     0.288  0.693  0.0  0.000   0.000  0.0  0.000   0.0
1  0.000     0.575  0.000  0.0  0.000   1.386  0.0  0.000   0.0
2  1.386     0.000  0.000  0.0  1.386   0.000  0.0  1.386   0.0
3  0.000     0.288  0.693  0.0  0.000   0.000  0.0  0.000   0.0
"""

コーパスの全ての文書に含まれていた、 is, the, this の idf値とtfidf値が0になっていることが確認できます。
また、他の単語についても、出現回数分値が落ちているのがみて取れます。

文書をTfidfVectorizerでベクトル化したときの正規化について

scikit-learnのtfidfについての記事の2本目です。
今回は結果のベクトルの正規化について紹介していきます。
前の記事でも書きましたが、TfidfVectorizerはデフォルトでは結果のベクトルを長さが1になるように正規化します。
参考: tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

ドキュメントのsklearn.feature_extraction.text.TfidfVectorizerのページの norm の説明にある通りです。

一応実験しておきます。サンプルの文章はUser Guideのページから拝借しました。


import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer

# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='l2',  # デフォルト
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first     is    one  second    the  third   this
0  0.000     0.470   0.58  0.384  0.000   0.000  0.384  0.000  0.384
1  0.000     0.688   0.00  0.281  0.000   0.539  0.281  0.000  0.281
2  0.512     0.000   0.00  0.267  0.512   0.000  0.267  0.512  0.267
3  0.000     0.470   0.58  0.384  0.000   0.000  0.384  0.000  0.384
"""

# ベクトルの長さが1であることの確認
print((tfidf.toarray()**2).sum(axis=1)**0.5)
# [1. 1. 1. 1.]

さて、ドキュメントにnorm{‘l1’, ‘l2’}, default=’l2’とある通り、norm='l1'と指定することもできます。
そうすると、ベクトルの長さを1にする代わりに、各要素の絶対値の和が1になるように正規化してくれます。
これも実験しておきましょう。なお、tfidfの結果は元々各成分がプラスなので、絶対値を取る処理は省略しています。
厳密にやるのであれば和をとる前にnp.abs()にかけましょう。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='l1',
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first     is    one  second    the  third   this
0  0.000     0.213  0.263  0.174  0.000    0.00  0.174  0.000  0.174
1  0.000     0.332  0.000  0.136  0.000    0.26  0.136  0.000  0.136
2  0.219     0.000  0.000  0.114  0.219    0.00  0.114  0.219  0.114
3  0.000     0.213  0.263  0.174  0.000    0.00  0.174  0.000  0.174
"""

# ベクトルの要素の和が1であることを確認
print(tfidf.toarray().sum(axis=1))
# [1. 1. 1. 1.]

ドキュメントでは、normには'l1'と'l2'の値しかサポートされていなように書いてありますが、実は他にも指定できる文字があります。
と言うのもGithubのソースコード(この記事を書いている時点ではバージョンは0.24.0)では、次のように実装されているからです。
参考: 該当箇所


        if self.norm:
            X = normalize(X, norm=self.norm, copy=False)

sklearn.preprocessing.normalize が呼び出されてます。
そして、そのドキュメントをみると、'l1','l2'に加えて'max'も対応しています。
'max'は、一番絶対値が大きい要素の絶対値が1になるように、要するに全部の要素の値が-1から1の範囲に収まるように、そして絶対値が一番大きい成分の値が±1になるように正規化してくれます。
ドキュメントに無い使い方なので、ご利用される場合は自己責任でお願いしたいのですが、次のコードの通り問題なく動作します。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='max',
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
   and  document  first     is  one  second    the  third   this
0  0.0      0.81    1.0  0.662  0.0   0.000  0.662    0.0  0.662
1  0.0      1.00    0.0  0.409  0.0   0.783  0.409    0.0  0.409
2  1.0      0.00    0.0  0.522  1.0   0.000  0.522    1.0  0.522
3  0.0      0.81    1.0  0.662  0.0   0.000  0.662    0.0  0.662
"""

また、もう一つ、これもドキュメントには無いのですが、正規化しないようにすることも可能です。
if self.norm:
とif文で分岐していますので、ここでFalseと判定される値を入れておけば大丈夫です。
FalseやNoneを入れておけば正規化されません。
(空白文字列でも、空配列でも、0でもお好きな値を使えますが、あまりトリッキーなことをしても意味はないのでFalseで良いでしょう)


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False, 
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
1  0.000     2.446  0.000  1.0  0.000   1.916  1.0  0.000   1.0
2  1.916     0.000  0.000  1.0  1.916   0.000  1.0  1.916   1.0
3  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
"""

次の記事からtfとidfの定義に関するオプションをいじって挙動を見ていくのですが、
その際は違いを見やすくするために正規化は行わないで試していきます。

tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

テキストをベクトル化するときに用いる基本的な方法の一つであるtf-idfについてこれから数回の更新でまとめていこうと思います。
最初にこの記事では一般的な定義と、scikit-learn(TfidfVectorizer)で引数を特に指定しなかった場合に実行されるデフォルトの定義について紹介し、
今後の記事でそのバリエーションとして、オプションを変えるとどのような定義で計算できるのかを紹介していく予定です。

さて、一言にtf-idfといってもその定義には非常に多くのバリエーションがあります。
英語版のWikipediaを見ていただくとそれらが紹介されています。
参考: tf–idf – Wikipedia (English)

一方で日本語版(この記事執筆時点)のWikipediaではそのうちの1個が紹介されています。
一般的にはこれを指すことが多そうなので、この記事ではこれを一般的な定義と呼びましょう。
参考: tf-idf – Wikipedia (日本語)

早速一般的な定義について紹介していきます。
文書$d$のtf-idfは、コーパス中の単語数に等しい長さのベクトルです。
そのベクトル中の単語$t$に対応する成分$\text{tfidf}_{t,d}$は次のように単語の出現頻度(Term Frequency)と、逆文書頻度(Inverse Document Frequency)の積として計算されます。
$$
\text{tfidf}_{t,d} = \text{tf}_{t,d}\cdot \text{idf}_{t}
$$
単語の出現頻度の定義は次のようになります。
$$
\text{tf}_{t,d} = \frac{\text{文書d中の単語tの出現回数}}{\text{文書dの全ての単語の出現回数の和}}
$$
そして、逆文書頻度の定義は次のようになっています。
$$
\text{idf}_{t} = \log{\frac{\text{総文書数}}{\text{単語tを含む文書数}}}
$$

ある文書に登場する単語は、その文書に多く登場するほど、tfが高まり、コーパス全体で珍しいほどidfが高まって、結果的にtfidfが高くなるようになります。

さて、ここまでが一般的なtfidfの定義の話でした。
普段、文書をtfidfでベクトル化するときは、僕はsciki-learnのTfidfVectorizerを使うことが多いです。
そのため、重要なのはTfidfVectorizerでどのように実装されているのかです。

User Guide をみると、tf, idfそれぞれ一般的なものとは違う設計になっていることがわかります。
参考: 6.2.3. Text feature extraction

まず、一番大きな特徴として、TfidfVectorizer(デフォルト設定)でベクトル化した文書はベクトルの長さが1になるように正則化されます。
これは単純に、tfidfベクトルに変換した後に、結果のベクトルをそれ自体の長さで割って返す実装になっています。

次に、tf,idfの定義がそれぞれ一般的なものと違います。
tf は、単語の出現頻度ではなく、単純に出現回数になっています。

$$
\text{tf}_{t,d} = \text{文書d中の単語tの出現回数}
$$

そして、idfについては、$\log$の中の分数の分子と分母にそれぞれ$1$を足す平滑化処理が行われており、さらに、最後に$1$が足されています。
つまり、TfidfVectorizerのidfは次の式で計算されます。
$$
\text{idf}_{t} = \log{\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}}+1
$$

これらの3つの特徴と、オプションを変えた場合の挙動について今後の記事で紹介していきたいと思います。
– ベクトルの正規化
– tfの定義の違い
– idfの定義の違い

Random projection について

前回の記事で紹介した、Johnson–Lindenstraussの補題を理論的背景に持つ次元削減の手法として、
Random projection と呼ばれるものがあります。
参考: Random projection – Wikipedia

手法は至ってシンプルで、乱数で生成した行列を掛けるによってデータを低次元へと埋め込んでしまうようです。

補題が主張する、距離を維持できる線形写像があるぞって話と、
ランダムに生成した行列で定義される線型写像で距離が維持できるぞと言う話は、
かなり論理にギャップがあるように感じ、その間のロジックをまだ正確には追えていないのですが、
scikit-learnに実装があるのでまずは試してみることにしました。

2000次元のデータを1000件用意し、Gaussian Random Projection という要するに正規分布で生成した行列で低次元にうつす手法を使い、
距離が保たれていることを確認します。

まずはデータの生成です。


import numpy as np

X = np.random.randn(1000, 2000)*10
print(X.shape)
# (1000, 2000)

続いて、Gaussian Random Projection のモデルのインスタンスを生成して、学習、変換します。
ハイパーパラメーター$\varepsilon$は$0.5$としました。
ドキュメントによると、次元削減後の次元は
$n\_components >= 4 \log(n\_samples) / (eps^2 / 2 – eps^3 / 3)$
となるようです。


print(4*np.log(len(X))/(eps**2/2-eps**3/3))
# 331.5722533911425

ですから、 332次元に圧縮されるかと思いきや、結果は331次元になります。
(ドキュメントのミスなのかバグなのか不明)


from sklearn.random_projection import GaussianRandomProjection


eps = 0.5
grp = GaussianRandomProjection(eps=eps)
grp.fit(X)
X_new = grp.transform(X)
print(X_new.shape)
# (1000, 331)

これで、元々2000次元だったデータを331次元に圧縮することができました。
それも、331×2000次元のランダム行列を掛けただけという、超単純な方法でです。

ちなみにその行列自体は grp.components_で取得できます。


print(grp.components_.shape)
# (331, 2000)
print(grp.components_.mean())
# -7.730060662586934e-05
print(grp.components_.var())
# 0.0030132642015398016

要素の平均はほぼ$0$であり、分散は$1/331=0.0030211…$に近い値になっており、ドキュメント通りであることが確認できます。

さて、最後に距離がある程度保存できていることを確認しておきましょう。
元のデータ X と、変換後のデータX_new それぞれについて距離行列を算出します。
そして、比率(変化率)を算出しその最小最大を見てみましょう。


X_pdist = pdist(X)
X_new_pdist = pdist(X_new)

print((X_new_pdist/X_pdist).min(), (X_new_pdist/X_pdist).max())
# 0.8230255694414008 1.18365715425175

eps が 0.5 だったので、 0.5倍〜1.5倍の範囲に収まる想定だったのですが、それよりもずっと良い精度で距離を維持できていることがわかりました。
今回は元のデータが正規分布だったので簡単だったのかもしれませんね。

Johnson–Lindenstraussの補題

『Pythonではじめる教師なし学習 ―機械学習の可能性を広げるラベルなしデータの利用』
という本の中で、「Johnson–Lindenstraussの補題」と言うものを知ったのでその紹介です。

これは、(高次元の)ユークリッド空間内の要素をそれぞれの要素間の距離をある程度保ったまま、
別の(低次元の)ユークリッド空間へ線型写像で移せることを主張するものです。

英語版のWikipediaに主張があるので、それを日本語訳しました。
Johnson–Lindenstrauss lemma – Wikipedia

$\varepsilon$を$0 < \varepsilon < 1$とし、$X$を $\mathbb{R}^N$内の$m$点の集合とします。さらに、$n$が$n > 8\log(m)/\varepsilon^2$を満たすとします。
すると、線型写像 $f:\mathbb{R}^N \longrightarrow \mathbb{R}^n$であって、
任意の$u, v \in X$ に対して、
$$
(1-\varepsilon)\|u-v\|^2 \leq \|f(u)-f(v)\|^2 \leq (1+\varepsilon)\|u-v\|^2
$$
を満たすものが存在する。

僕の個人的な感想ですが、この補題のすごいところは$n$の条件に元の次元$N$が含まれていないことです。
$N$が非常に大きな数だった場合に、要素間の距離をそこそこ保ったままはるかに小さな次元に埋め込める可能性を秘めています。

その一方で、$N$が小さく、要素数$m$が極端に大きい場合は、次元削減としての効果はあまり得られません。

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

前回に引き続き、pyLDAvisを用いたトピックモデルの可視化です。
今回は gensim 編です。

gensim で学習したトピックモデルの可視化の使い方はこちらの notebook で紹介されています。

scikit-learn と違うのは、
インポートするモジュールが、pyLDAvis.gensimに代わり、
呼び出すメソッドがpyLDAvis.gensim.prepareになる点だけですね。
メソッドに渡すのは、 学習したモデル、コーパスのデータ(BOW形式)、コーパス作成に使った辞書 である点も同じです。

以前書いた、
gensimでトピックモデル(LDA)をやってみる
のコードで学習したモデルでやってみましょう。
(トピック数だけ20に増やして実行しました。)


import pyLDAvis
import pyLDAvis.gensim
pyLDAvis.enable_notebook()

pyLDAvis.gensim.prepare(lda, corpus, dictionary)

これで、sickit-learnの時と同様に結果を得ることができます。

これだけだと何も代わり映えしないので、せっかくなので、次元削減のメソッドを変更して結果をそれぞれみてみましょう。

次の3行のコードを実行した結果を1枚の画像にまとめたのが次の出力です。


pyLDAvis.gensim.prepare(lda, corpus, dictionary, mds="pcoa") # デフォルト
pyLDAvis.gensim.prepare(lda, corpus, dictionary, mds="mmds")
pyLDAvis.gensim.prepare(lda, corpus, dictionary, mds="tsne")

次元削減のアルゴリズムの特徴が出ていますね。

これはスクリーンショットなので動かせませんが、実際にjupyter notebookで表示したりHTMLに保存したものはグリグリ動かしてみることができるので是非試してみてください。

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種類です。

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が小さければいいモデルだとは言えない、と言う議論もあるのですが、
今日の記事の範囲は超えますので省略します。)