はじめに
先日開催した社内勉強会では、書籍『Pythonではじめる 情報検索プログラミング』を教材として、情報検索の基礎について学びました。
本記事では、その中でも「トピックモデル」に関する内容を整理します。
トピックモデルは、文書に含まれる単語の出現傾向から、その文書の背後にある「意味のまとまり」を推定するための手法です。
たとえば、ニュース記事の中に「試合」「選手」「ゴール」といった単語が多く出てくる場合、その記事は「スポーツ」に関係していると考えられます。
このように、文書を単語そのものではなく、より大きな意味のまとまりで捉えることで、検索・分類・クラスタリングなどに活用しやすくなります。
今回は、代表的なトピックモデルとして、LSI(潜在的意味解析)とLDA(潜在的ディリクレ配分法)を取り上げます。
書籍で扱われている考え方をもとにしつつ、図やサンプルコードは理解しやすいように整理し直して紹介します。
書籍リンク:https://www.morikita.co.jp/books/mid/081861
トピックモデルとは
たとえば、ニュース記事を分類することを考えます。
ある記事には「ゴール」「試合」「選手」といった単語が多く出てくるかもしれません。
この場合、その記事は「スポーツ」に関係していると考えられます。
別の記事には「政府」「政策」「予算」といった単語が多く出てくるかもしれません。
この場合、その記事は「政治」に関係していると考えられます。
このように、文書に出てくる単語の傾向から、その文書の背後にあるトピックを推定するのがトピックモデルです。

トピックモデルを使うと、文書を単語そのものではなく、より大きな意味のまとまりで扱えるようになります。
これにより、文書検索・分類・クラスタリングなどで、単語単位よりも柔軟な表現が可能になります。
本章では、代表的なトピックモデルとして、次の2つを扱います。
| 手法 | 概要 |
|---|---|
| LSI | 特異値分解を使って、文書ベクトルを低次元のトピック空間へ圧縮する手法 |
| LDA | 文書は複数トピックの混合であり、各トピックは単語の確率分布を持つと考える手法 |
LSI:潜在的意味解析
LSI(Latent Semantic Indexing)は、文書と単語の関係を表す行列を、より少ない次元に圧縮する手法です。
文書をBag-of-Wordsで表すと、各文書は「どの単語が何回出現したか」というベクトルになります。
しかし、単語の種類が多くなると、文書ベクトルの次元も大きくなります。
また、「AI」と「人工知能」のように、意味は近いが単語としては異なるものもあります。
単語をそのまま扱うだけでは、このような意味の近さをうまく表現できない場合があります。
LSIでは、文書×単語の行列に対して特異値分解を行い、重要な成分だけを残します。
これにより、文書を単語ベースではなく、少数の潜在的なトピック軸で表現できるようになります。

LSIの考え方を簡単に表すと、次のようになります。
$$
X \approx U_k S_k V_k^T
$$
ここで、Xは文書×単語の行列です。
LSIでは、この行列を分解し、重要度の高い成分だけを残すことで、文書を低次元の空間に写します。
元の単語数が多くても、トピック数を小さく設定すれば、文書をより少ない次元で扱うことができます。
これがLSIにおける次元削減です。
LSIのサンプル
ここでは、確認用に小さなニュース記事風の文書集合を用意します。
各文書は、あらかじめ分かち書きされているものとします。
docs = {
"記事A": "サッカー 試合 ゴール 選手 チーム 勝利",
"記事B": "野球 試合 選手 チーム 監督 勝利",
"記事C": "サッカー 選手 監督 ゴール リーグ",
"記事D": "政府 政策 予算 国会 改革",
"記事E": "選挙 政府 政策 国会 議員",
"記事F": "内閣 政策 予算 改革 議員",
"記事G": "市場 投資 景気 企業 経済",
"記事H": "企業 決算 投資 市場 成長",
"記事I": "経済 景気 成長 投資 企業",
}
記事A〜Cはスポーツ、記事D〜Fは政治、記事G〜Iは経済に近い内容です。
まず、この文書集合をBag-of-Wordsで表現します。
Bag-of-Wordsは、各文書にどの単語が何回出現したかを表す方法です。
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import TruncatedSVD
import pandas as pd
texts = list(docs.values())
doc_names = list(docs.keys())
vectorizer = CountVectorizer(token_pattern=r"(?u)\b\w+\b")
X = vectorizer.fit_transform(texts)
words = vectorizer.get_feature_names_out()
bow_df = pd.DataFrame(
X.toarray(),
index=doc_names,
columns=words
)
bow_df
この X が、文書×単語の行列です。
各行が文書、各列が単語を表しています。
次に、この行列に対してLSIに相当する次元削減を行います。
実装では、特異値分解に基づく TruncatedSVD を使います。
lsi = TruncatedSVD(n_components=3, random_state=0)
lsi_doc_topic = lsi.fit_transform(X)
lsi_df = pd.DataFrame(
lsi_doc_topic,
index=doc_names,
columns=["Topic1", "Topic2", "Topic3"]
)
lsi_df.round(3)
実行すると、次の結果が得られます。
Topic1 Topic2 Topic3
記事A 2.238 -0.000 -0.000
記事B 2.076 -0.000 -0.000
記事C 1.588 -0.000 -0.000
記事D 0.000 0.000 1.998
記事E 0.000 0.000 1.784
記事F 0.000 0.000 1.784
記事G 0.000 2.042 -0.000
記事H 0.000 1.831 -0.000
記事I 0.000 2.042 -0.000
この結果では、記事A〜CがTopic1、記事G〜IがTopic2、記事D〜FがTopic3に強く反応しています。
つまり、文書をもともとの単語数ではなく、3つのトピック軸で表現できていることがわかります。
さらに、それぞれのトピックに関係の強い単語を確認してみます。
def show_lsi_topics(model, words, top_n=6):
rows = []
for topic_id, component in enumerate(model.components_, start=1):
top_indices = component.argsort()[-top_n:][::-1]
top_words = [words[i] for i in top_indices]
rows.append({
"Topic": f"Topic{topic_id}",
"上位単語": "、".join(top_words)
})
return pd.DataFrame(rows)
show_lsi_topics(lsi, words)
実行結果は次のとおりです。
| Topic | 上位単語 |
|---|---|
| Topic1 | 選手、チーム、勝利、試合、サッカー、ゴール |
| Topic2 | 企業、投資、経済、景気、成長、市場 |
| Topic3 | 政策、国会、政府、改革、予算、議員 |
Topic1にはスポーツに関係する単語が多く、Topic2には経済、Topic3には政治に関係する単語が多く出ています。
このようにLSIでは、文書×単語行列を圧縮することで、文書を少数の意味的な軸で表現できます。
LSIのメリットと注意点
LSIのメリットは、文書を低次元に圧縮できることです。
これにより、類似文書検索やクラスタリングを行う際に、単語そのものではなく、潜在的な意味のまとまりを使えるようになります。
一方で、LSIでは計算結果の値が負になることがあります。
そのため、各トピックが何を意味しているのかを、人間が直感的に解釈しにくい場合があります。
また、得られるトピックは、必ずしも人間が自然に考えるカテゴリと一致するとは限りません。
LSIはあくまで、行列を低次元に近似することで、文書の背後にある構造を取り出す手法です。
LDA:潜在的ディリクレ配分法
LDA(Latent Dirichlet Allocation)は、文書を複数のトピックの混合として表す手法です。
LSIでは、文書を低次元の空間に写すことでトピックを扱いました。
一方、LDAでは、文書は複数のトピックから構成されており、各トピックは単語の出やすさを持っていると考えます。
たとえば、あるニュース記事は次のように表せます。
| 文書 | スポーツ | 政治 | 経済 |
|---|---|---|---|
| 記事A | 0.80 | 0.10 | 0.10 |
| 記事B | 0.10 | 0.75 | 0.15 |
| 記事C | 0.20 | 0.20 | 0.60 |
この表は、各文書がどのトピックをどれくらい含んでいるかを表しています。
これを 文書-トピック分布 と呼びます。
また、各トピックは、どの単語が出やすいかを持っています。
| トピック | 出やすい単語の例 |
|---|---|
| スポーツ | 試合、ゴール、選手、チーム |
| 政治 | 政府、政策、予算、国会 |
| 経済 | 市場、投資、景気、企業 |
これを トピック-単語分布 と呼びます。

LDAでは、観測できるのは実際の文書に出現した単語だけです。
文書-トピック分布やトピック-単語分布は、最初から見えているわけではありません。
LDAは、観測された単語の出現パターンから、これらの分布を推定します。
LDAのサンプル
LSIと同じ文書集合に対して、LDAを適用してみます。
from sklearn.decomposition import LatentDirichletAllocation
lda = LatentDirichletAllocation(
n_components=3,
random_state=42,
learning_method="batch",
max_iter=200,
doc_topic_prior=0.1,
topic_word_prior=0.1
)
lda_doc_topic = lda.fit_transform(X)
lda_df = pd.DataFrame(
lda_doc_topic,
index=doc_names,
columns=["Topic1", "Topic2", "Topic3"]
)
lda_df.round(3)
実行すると、次の結果が得られます。
Topic1 Topic2 Topic3
記事A 0.016 0.016 0.968
記事B 0.016 0.016 0.968
記事C 0.019 0.208 0.774
記事D 0.962 0.019 0.019
記事E 0.962 0.019 0.019
記事F 0.962 0.019 0.019
記事G 0.019 0.962 0.019
記事H 0.019 0.962 0.019
記事I 0.019 0.962 0.019
LDAでは、各文書がどのトピックをどの程度含むかが、確率分布として出力されます。
たとえば、記事Aと記事BはTopic3の値が高く、記事D〜FはTopic1の値が高くなっています。
次に、各トピックで出やすい単語を確認します。
def show_lda_topics(model, words, top_n=6):
rows = []
for topic_id, topic in enumerate(model.components_, start=1):
top_indices = topic.argsort()[-top_n:][::-1]
top_words = [words[i] for i in top_indices]
rows.append({
"Topic": f"Topic{topic_id}",
"上位単語": "、".join(top_words)
})
return pd.DataFrame(rows)
show_lda_topics(lda, words)
実行結果は次のとおりです。
| Topic | 上位単語 |
|---|---|
| Topic1 | 政策、議員、政府、改革、国会、予算 |
| Topic2 | 企業、投資、経済、成長、景気、市場 |
| Topic3 | 選手、試合、勝利、チーム、サッカー、ゴール |
この結果を見ると、Topic1は政治、Topic2は経済、Topic3はスポーツに近いトピックとして解釈できます。
ここで重要なのは、トピック番号そのものに意味があるわけではないという点です。
Topic1が政治になるか、Topic2が政治になるかは、実行条件やデータによって変わることがあります。
大事なのは、各トピックにどの単語が多く含まれているかを見て、人間が意味づけを行うことです。
LDAのメリットと注意点
LDAのメリットは、文書が複数のトピックを含むことを自然に表現できる点です。
たとえば、政治と経済の両方を扱う記事のように、1つのカテゴリに分けにくい文書でも、トピックの混合として表せます。
また、LDAでは、文書ごとのトピック分布や、トピックごとの単語分布が確率として出力されます。
そのため、LSIに比べると、結果を解釈しやすい場合があります。
一方で、LDAではトピック数を事前に決める必要があります。
トピック数が少なすぎると、大きくまとまりすぎます。
逆に多すぎると、細かく分かれすぎて解釈が難しくなります。
また、推定結果は初期値やパラメータの影響を受けるため、毎回まったく同じ結果になるとは限りません。
LSIとLDAの違い
LSIとLDAは、どちらも文書をトピックの観点から表現する手法です。
ただし、考え方には違いがあります。

LSIは、文書×単語の行列を低次元に圧縮する手法です。
数式的には、特異値分解によって重要な軸だけを残すことで、文書を少数のトピック軸で表します。
一方、LDAは、文書が複数のトピックから生成されると考える確率モデルです。
文書ごとのトピック分布と、トピックごとの単語分布を推定します。