tfidf、LSI、LDAの違いについて調べてみた

tfidf、LSI、LDAの意味、違いを調べるために、それぞれの形式のコーパスの中身を調べてみた。そのメモ。



前回のおさらい


前回の記事では、もっとも基本的なコーパスの中身を確認してみました。その結果、「コーパスとは、文章集合をベクトル空間に変換したもの」いうことが分かりました。


今回は、基本的なコーパス以外の複数のコーパス、特に、tfidf、LSI、LDAで用いるコーパスについて、基本的なコーパスとは何が違うのかを調べます。その結果分かったコーパスの違いから、各モデルの違いを理解することを目標とします。



gensimに実装されたtfidfのコーパスの中身を見てみました


今回は、「Topics and Transformations」を参考に進めていきます。


>>> import logging
>>> logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

# フォーマットしたprintのために必要
>>> import pprint

>>> from gensim import corpora, models, similarities

# dictionary, corpusをファイルから読み込む
>>> dictionary = corpora.Dictionary.load('./deerwester.dict')
>>> corpus = corpora.MmCorpus('./deerwester.mm')

>>> print corpus
MmCorpus(9 documents, 12 features, 28 non-zero entries)

途中で読み込んでいるファイルは、前回の記事で作成したものです。


今回参考にしているチュートリアルには適宜大事なことが書いてあるので、その翻訳も書きます。訳が完璧である自信がないので、原文も合わせて読んでみることをおすすめします。


このチュートリアルでは、文章の集合をあるベクトル表現から他のベクトル表現に変換する方法を説明します。このプロセスには、以下の2つのゴールがあります。

1. コーパスの中の隠れた構造を明らかにする。その結果として、単語とその利用形態の関係性を発見し、新しい他のセマンティック解析手法を適用可能にすること

2. 文章をよりコンパクトに表現し、リソース消費効率(計算時間やHDDの容量)とノイズリダクションの両方の効果を高めること
- Topics and Transformations


各モデル(tfidf、LSI、LDA)を使ってベクトル空間の変換を行う目的は上記の通りらしいです。


>>> tfidf = models.TfidfModel(corpus)

# ファイルに保存・ファイルから読み込みするとき
>>> tfidf.save('./model.tfidf')
>>> tfidf = models.TfidfModel.load('./model.tfidf')

tfidfモデルのデータを作成しました。コーパスを使ってあるモデルを初期化することを、訓練する(train)と呼ぶこともあるみたいです。


tfidfモデル等は、あるベクトル空間をあるベクトル空間に変換します。あるベクトル空間のtrainに使った単語とIDのマッピングは、その後のベクトル空間間の変換にも使われる必要があります。

同じfeature space(単語とIDのマッピング)を使うことを怠る、例えば、異なるプリプロセッシング(不要な単語を除いたりの部分)を行ったり、異なったfeature ids(単語とIDのマッピング)を使ったり、tfidfベクトルが求められているところにbag-of-wordsに基づくベクトル空間を使ったりすると、予期せぬ結果になったり、単にプログラムのエラーが起きたりします。
- Topics and Transformations


上記は意訳なんですが、さらにざっくり言うと、最初に作った単語とIDのマッピングを使いましょう(同じ単語に違うIDをふったらそりゃまずいですよね)とか、求められるデータ形式に合わせたデータを入力にしましょう、みたいな感じです。


>>> doc_bow = [(0, 1), (1, 1)]
>>> print tfidf[doc_bow]
[(0, 0.70710678), (1, 0.70710678)]

tfidfオブジェクトを使うことで、「bag-of-wordsに基づくベクトル表現」を「tfidf重み付けを適用したベクトル表現」に変換することが可能になりました。


上記の変換を行列表現に置き換えると下記のようになります。


# 前回の記事で作成したdictionaryは12個の単語からなる=doc_bowは12個の要素を持ったベクトル、なので、
doc_bow = [(0, 1), (1, 1)]
        = | 1 1 0 0 0 0 0 0 0 0 0 0 |

# 横のスペースの都合上、xとyを使いました
x = 0, 0.70710678
y = 1, 0.70710678
tfidf[doc_bow] = [(x), (y)]
               = | x y 0 0 0 0 0 0 0 0 0 0 |

tfidfオブジェクトの中身が何なのかは知らないですが、doc_bowベクトルが他のベクトルに変換されたことは分かります。その変換は、「各要素の位置を変えずに、各要素の値を変更する」ものであることも上記から分かります。


tfidfは単語の出現頻度だけでなく文章全体に占める割合も重みとして考慮するモデルです。その重みを適用した結果が上記の変換である、とも言えます。


>>> corpus_tfidf = tfidf[corpus]
>>> for doc in corpus_tfidf:
>>>     print doc
[(0, 0.57735026918962573), (1, 0.57735026918962573), (2, 0.57735026918962573)]
[(0, 0.44424552527467476), (3, 0.44424552527467476), (4, 0.44424552527467476), (5, 0.32448702061385548), (6, 0.44424552527467476), (7, 0.32448702061385548)]
[(2, 0.5710059809418182), (5, 0.41707573620227772), (7, 0.41707573620227772), (8, 0.5710059809418182)]
[(1, 0.49182558987264147), (5, 0.71848116070837686), (8, 0.49182558987264147)]
[(3, 0.62825804686700459), (6, 0.62825804686700459), (7, 0.45889394536615247)]
[(9, 1.0)]
[(9, 0.70710678118654746), (10, 0.70710678118654746)]
[(9, 0.50804290089167492), (10, 0.50804290089167492), (11, 0.69554641952003704)]
[(4, 0.62825804686700459), (10, 0.45889394536615247), (11, 0.62825804686700459)]

# 比較のためにcorpusのprint結果も掲載
[(0, 1), (1, 1), (2, 1)]
[(0, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1)]
[(2, 1), (5, 1), (7, 1), (8, 1)]
[(1, 1), (5, 2), (8, 1)]
[(3, 1), (6, 1), (7, 1)]
[(9, 1)]
[(9, 1), (10, 1)]
[(9, 1), (10, 1), (11, 1)]
[(4, 1), (10, 1), (11, 1)]

今度は、corpus全体をtfidfオブジェクトで変換しました。どんな重みなのかは分からないですが、重み付けが考慮されることで、半端な値になっているということは分かります。


tfidfオブジェクトを作るために用いたcorpusをさらにtfidfオブジェクトで変換するのが不思議に感じますが、これはこれでOKらしいです。


「tfidfオブジェクトをcorpus(=最初に与えた9個の文章ベクトルが張るベクトル空間)でtrainした。このtfidfオブジェクトは任意のベクトルを変換可能であるため、corpus(を構成する9個の行ベクトル)を変換することも、もちろん可能である」ということらしいです。


ここまででやっと、最初のコーパス(仮にbowコーパスと呼びます)と、比較対象になるtfidfで変換したコーパス(仮にtfidfコーパス)がでてきました。


ちなみに、bowはbag-of-wardsのことです。



bowコーパスとtfidfコーパスの違いは重み付けをしているかどうか


ここまでに書いた通り、bowコーパスとtfidfコーパスの違いは、重み付けを考慮しているかどうかです。


この重みの具体的な中身は、長くなるので説明しません。「tfidf」で検索してみてくだい。tfidf利用者としては、「この重み付けのおかげで精度が上がる」くらいの理解でいいと思います。


bowコーパスからtfidfコーパスへの変換では、各行ベクトルの要素数は同じままでした。つまり、bowコーパスが張るベクトル空間は12次元のままでした。次元数=単語数なので、このままだと単語が増えた分だけ無駄な情報量が増えてしまうことになります。


次にでてくるLSIモデルは、元の文章集合にトピックという概念を持ち込むことで、次元数を大幅に縮約します。



gensimに実装されたLSIモデルの中身を見てみました


>>> lsi = models.LsiModel(corpus_tfidf, id2word=dictionary, num_topics=2)

# ファイルに保存・ファイルから読み込みするとき
>>> lsi.save('./model.lsi')
>>> lsi = models.LsiModel.load('./model.lsi')

# 元のコーパスを二回変換して使っている: bowコーパス->tfidfコーパス->fold-in-lsi(LSIモデルに置けるコーパスの変換のこと)
>>> corpus_lsi = lsi[corpus_tfidf]

上記の手順により、tfidfコーパスをLSI(Latent Semantic Indexing)モデルに基づく潜在的な二次元空間(latent 2-D space)に変換しました。num_topics=2を指定しているため二次元になっています。


それではまず、LSIをモデルにおけるトピックの中身を見ていきます。


>>> lsi.print_topics(2)
topic #0(1.594): -0.703*"trees" + -0.538*"graph" + -0.402*"minors" + -0.187*"survey" + -0.061*"system" + -0.060*"response" + -0.060*"time" + -0.058*"user" + -0.049*"computer" + -0.035*"interface"
topic #1(1.476): -0.460*"system" + -0.373*"user" + -0.332*"eps" + -0.328*"interface" + -0.320*"response" + -0.320*"time" + -0.293*"computer" + -0.280*"human" + -0.171*"survey" + 0.161*"trees"

再度になりますが、「トピック」とは、平たく言うと、「話題」のことです。LSIモデルの変換を通して、最初の9個の文章を、二つのトピックに分解しました。


上記のprint_topicsの結果は、各トピックを構成する単語とその貢献率を表しています。トピック0にはtrees、graph、minorsの3つの単語が大きく貢献していることが分かります。トピック1にはsystem、user、epsという単語が貢献しているようです。


これはつまり、今までは単語数=次元数だったが、LSIモデルによる変換を行うことで、次元数=トピック数<単語数になった、つまり次元数が大幅に縮約された、ということです。


このことは、lsiコーパスの出力結果からも分かります。


# both bow->tfidf and tfidf->lsi transformations are actually executed here, on the fly
>>> for doc in corpus_lsi:
>>>     print doc
[(0, -0.066), (1, 0.520)] # "Human machine interface for lab abc computer applications"
[(0, -0.197), (1, 0.761)] # "A survey of user opinion of computer system response time"
[(0, -0.090), (1, 0.724)] # "The EPS user interface management system"
[(0, -0.076), (1, 0.632)] # "System and human system engineering testing of EPS"
[(0, -0.102), (1, 0.574)] # "Relation of user perceived response time to error measurement"
[(0, -0.703), (1, -0.161)] # "The generation of random binary unordered trees"
[(0, -0.877), (1, -0.168)] # "The intersection graph of paths in trees"
[(0, -0.910), (1, -0.141)] # "Graph minors IV Widths of trees and well quasi ordering"
[(0, -0.617), (1, 0.054)] # "Graph minors A survey"

丸括弧の中の左側の要素が、0と1しかとっていません。これはトピックのIDです。それに合わせて、各行ベクトルの(非0の)要素数は2になっています。これを行列で表現すると下記のようになります。


# lsiコーパスの行列表現
| -0.066  0.520 |
| -0.197  0.761 |
| -0.090  0.724 |
| -0.076  0.632 |
| -0.102  0.574 |
| -0.703 -0.161 |
| -0.877 -0.168 |
| -0.910 -0.141 |
| -0.617  0.054 |

# 比較のためにbowコーパスの行列表現も再掲
| 1 1 1 0 0 0 0 0 0 0 0 0 |
| 1 0 0 1 1 1 1 1 0 0 0 0 |
| 0 0 1 0 0 1 0 1 1 0 0 0 |
| 0 1 0 0 0 2 0 0 1 0 0 0 |
| 0 0 0 1 0 0 1 1 0 0 0 0 |
| 0 0 0 0 0 0 0 0 0 1 0 0 |
| 0 0 0 0 0 0 0 0 0 1 1 0 |
| 0 0 0 0 0 0 0 0 0 1 1 1 |
| 0 0 0 0 1 0 0 0 0 0 1 1 |

lsiコーパスの行列は、bowコーパスの行列よりも横の要素数が減っている=情報が圧縮されている、ということが上記の行列表現から分かります。



tfidfコーパスとlsiコーパスの違いは次元を縮約しているかどうか


ここまでで、tfidfコーパスとlsiコーパスの違いは次元を縮約しているかどうかである、ということが分かりました。次元を縮約することで、計算時間等のリソース使用量を効率化したり、ノイズを減らすことができるようになります。


LSIモデルの次元を縮約は特異値分解を利用しています。興味のある方は調べてみて下さい。LSIモデルの利用者としては、「LSIモデルはtfidfモデルの次元を縮約し、色々と効率をよくしたもの」ということだけ覚えていればよいと思います。



追加で知っておいたら便利なLSIの特徴


コーパスの話からはそれますが、自然言語処理モデルの利用者として知っておいたら便利なLSIの特徴をいくつか書いておきます。


LSIモデルを使うと存在しない単語も検索できる

tfidfもLSIも最終的には文章の類似度を比較するために用います。その際、tfidfでは検索対象に存在しない語を検索することはできないのですが、LSIだと存在しない語も意味が似ており最初の入力文章で同時に使われていれば検索することができます。


例えば、「スポーツ」で検索したとき、tfidfだと「スポーツ」が含まれる文章のみ検索で見付かります。LSIだと、「スポーツ」という単語が入っていなくても、「サッカー」や「野球」という単語が入っていれば検索で見付かります。


私も知らないので詳しい説明はできないですが、そういうものだと思っておいてください。



LSIモデル追加のtrainができる

また、LSIモデルには任意のタイミングで追加のtrainができる性質があります。コードを見た方が分かりやすいと思いますので貼ります。


# 一回目のtrain
>>> lsi = models.LsiModel(corpus_tfidf, id2word=dictionary, num_topics=2)

# 二回目のtrain。tfidf_corpusとanother_tfidf_corpusでtrainされている
>>> lsi.add_documents(another_tfidf_corpus)

>>> lsi_vec = lsi[tfidf_vec] # convert some new document into the LSI space, without affecting the model
>>> ...

# 三回目のtrain。tfidf_corpusとanother_tfidf_corpusとmore_documentsでtrainされている
>>> model.add_documents(more_documents)
>>> lsi_vec = model[tfidf_vec]
>>> ...

上記のコードはチュートリアルのサンプルコードです。三回目のtrainのとき、コーパスでなくdocumentsという変数を追加していますが、本当にdocuments(文章)でいいのかは不明です。二回目のtrainのようにコーパスを追加するのが無難だと思います。


ついでに、LSIモデルの初期化時にtfidfコーパスを引数で渡していますが、bowコーパスでもよいとチュートリアルには書いてあります。ただ、tfidfコーパスが推奨だそうです。



gensimに実装されたLDAモデルの中身を見てみました


最後に、LDAモデルについても同様にコーパスの形式を確認してみます。


# LDAの入力にはbowコーパスを使います。
>>> lda = models.LdaModel(bow_corpus, id2word=dictionary, num_topics=2)

# ファイルに保存・ファイルから読み込みするとき
>>> lda.save('./model.lda')
>>> lda = models.LdaModel.load('./model.lda')

# bowコーパスを変換してみます
>>> corpus_lda = lda[bow_corpus]

>>> lsi.print_topics(2)
topic #0: 0.118*user + 0.108*time + 0.106*response + 0.104*computer + 0.090*system + 0.087*survey + 0.076*trees + 0.074*human + 0.067*graph + 0.066*interface
topic #1: 0.152*system + 0.126*graph + 0.118*trees + 0.101*eps + 0.086*minors + 0.080*interface + 0.078*user + 0.072*human + 0.060*survey + 0.044*computer

>>> for doc in corpus_lsi:
>>>     print doc
[(0, 0.78087552571831131), (1, 0.21912447428168871)]
[(0, 0.90351881089506125), (1, 0.096481189104938817)]
[(0, 0.1592103271433552), (1, 0.84078967285664474)]
[(0, 0.12892761357840204), (1, 0.87107238642159801)]
[(0, 0.8578374533111004), (1, 0.14216254668889966)]
[(0, 0.31245643377046373), (1, 0.68754356622953627)]
[(0, 0.20255645298950298), (1, 0.79744354701049702)]
[(0, 0.15474862608654011), (1, 0.84525137391345984)]
[(0, 0.22012239738883982), (1, 0.77987760261116024)]

# tfidfコーパスを変換してみます
>>> corpus_lda = lda[bow_corpus]

# bowコーパスのときと同じですね
>>> lda.print_topics(2)
topic #0: 0.118*user + 0.108*time + 0.106*response + 0.104*computer + 0.090*system + 0.087*survey + 0.076*trees + 0.074*human + 0.067*graph + 0.066*interface
topic #1: 0.152*system + 0.126*graph + 0.118*trees + 0.101*eps + 0.086*minors + 0.080*interface + 0.078*user + 0.072*human + 0.060*survey + 0.044*computer

# bowコーパスのときとは異なっています!
>>> for doc in corpus_lda:
>>>     print doc
[(0, 0.69553154476057855), (1, 0.30446845523942151)]
[(0, 0.81404079217964198), (1, 0.18595920782035799)]
[(0, 0.24548453599086037), (1, 0.7545154640091396)]
[(0, 0.23776487842847563), (1, 0.7622351215715244)]
[(0, 0.79303029003538528), (1, 0.20696970996461483)]
[(0, 0.31249510476145798), (1, 0.68750489523854208)]
[(0, 0.25080808854906528), (1, 0.74919191145093478)]
[(0, 0.22824269457961743), (1, 0.77175730542038246)]
[(0, 0.32538127244383769), (1, 0.67461872755616226)]

作成したLDAモデルを使ってbowコーパスとtfidfコーパスを変換したとき、トピックの中身は同じになって、ldaコーパスの中身は異なったものになりました。


実際のアルゴリズムには詳しくないので正確な意味は分からないのですが、「本質的に同じもの(bowコーパスとtfidfコーパス)を入力に与えたからトピックは同じものになっている。しかし、実際のデータ表現としてのldaコーパスは違った値をとる」みたいな感じだと私は推測しました。



LSIモデルに確率的な揺らぎを取り入れたのがLDA


LSIもLDAも、次元をトピック単位に縮約するのは同じでした。lsiコーパスとldaコーパスの値もほとんど同じです。ただ、全く同じではなく、LSIとLDAには、「確率的な揺らぎを考慮するかどうか」という違いがあります。


LSIでは、一緒に使われている単語であれば、そのうち片方の単語で検索すると、もう片方の単語の結果も見付かることをちょっと前に書きました。しかしながら、どれだけ意味が似ている単語であっても、一緒に使われていなければそういう検索はできないという弱点があります。


LDAでは、LSIに確率分布を取り入れることで、直接的には一緒に使われていない単語同士であっても検索で見付けることができるようになります。


これまた詳しくないので詳細な説明はできないのですが、利用者の立場としては、LSIの発展版がLDAだ、くらいに思っておけばいいんじゃないかと思います。



まとめ


bow、tfidf、LSI、LDAの各形式に合わせたコーパスを見ることで、それぞれのモデルにどういう意味があるのか、それぞれのモデルはどう異なっているのか、を調べました。


この記事でたびたび、「検索する」という言葉を使ったのに検索のやり方に触れることができなかったので、次回はこれまで紹介したモデルを使った文章間の類似度比較(つまり検索みたいなもの)を説明したいと思います。


著者プロフィール
Webサイトをいくつか作っています。
著者プロフィール