🍔

Google Colabで形態素解析・LDAモデリング・WordCloud可視化まで

2024/04/05に公開

トピックモデルというテキストデータからトピックを抽出する手法を試してみました。

トピックモデルはテキストデータから潜在的なトピックを抽出する手法です。単一のクラスタリング手法であるk-meansとは異なり、以下のような特徴があります。

  • k-means: 各テキストデータはひとつのクラスタ(トピック)にのみ割り当てられる
  • トピックモデル: 各テキストデータは複数のトピックに割り当てられ、これらのトピックの分布で表現される

今回使用したLDA(Latent Dirichlet Allocation)は、最も有名なトピックモデルの実装で、「トピックモデル=LDA」と誤解されることもあるようです。飲食店の口コミからLDAでトピックを抽出し、口コミを分類、ついでにトピックをWordCloudで可視化するまでの手順をGoogle Colabで実装しました。

必要な依存のインストール

まずは必要なライブラリをインストールします。

!pip install mecab-python3 unidic-lite gensim wordcloud

今回使用したライブラリについて簡単に補足します。

  • mecab-python3:
    • 日本語の形態素解析のためのライブラリ
    • MeCabは単語を分割し、品詞を特定するなどのテキストデータの前処理によく使われる
  • unidic-lite:
    • MeCabを使用するために必要な辞書データ(軽量版)
  • gensim:
    • トピックモデリングや文書類似性の分析に使用されるライブラリ
    • LDAなどのトピックモデルが利用できる
  • wordcloud:
    • テキストデータからWordCloudを生成するためのライブラリ

形態素解析による名詞と代名詞の抽出

飲食店の口コミを想定したテキストデータを用意し、そこから形態素解析で単語を抽出します。詳細は省略しますが、すべての単語をそのまま使用すると、「が」や「。」などの単語が頻繁に登場し、意図通りの結果にならなかったので、名詞と代名詞だけを取り出すように修正してあります。

MeCabを使用しましたが、ちゃんと単語が抽出できているようでした。ただし、以下のような固有名詞や複合語は扱いが難しいようでした。

  • 「ワンピース」 => 「ワン」「ピース」
  • 「新とびきりバーガー」 => 「とびきり」「バーガー」
import MeCab

def extract_nouns_and_pronouns(text):
    tagger = MeCab.Tagger()
    tagger.parse('')
    node = tagger.parseToNode(text)
    words = []
    while node:
        word_type = node.feature.split(',')[0]
        if word_type in ["名詞", "代名詞"]:
            words.append(node.surface)
        node = node.next
    return words

text_samples = [
        "食事が美味しく、スタッフの対応も良かった。",
        "ロケーションは良いが、料理が期待ほどではなかった。",
        "スタッフは親切だが、部屋が狭すぎる。",
        "今までのオムライスで一番美味しいオムライスだった。",
        "部屋は広くて快適だったが、料理が平凡だった。",
]

processed_texts = [extract_nouns_and_pronouns(text) for text in text_samples]
# [
#   ['食事', 'スタッフ', '対応'],
#   ['ロケーション', '料理', '期待'],
#   ['スタッフ', '親切', '部屋'],
#   ['今', 'オムライス', 'オムライス'],
#   ['部屋', '快適', '料理', '平凡']
# ]

コーパスの生成

次に、形態素解析で抽出した単語を用いてコーパスを生成します。

トピックモデリングを行うためにはコーパスを作る必要があります。import corporaと書いていますが、corporacorpusの複数形だそうです。gensimdoc2bowは、テキストデータを単語とその出現回数を基にベクトル形式に変換する関数です。

from gensim import corpora

dictionary = corpora.Dictionary(processed_texts)

corpus = [dictionary.doc2bow(text) for text in processed_texts]
# [
#   [(0, 1), (1, 1), (2, 1)],
#   [(3, 1), (4, 1), (5, 1)],
#   [(0, 1), (6, 1), (7, 1)],
#   [(8, 2), (9, 1)],
#   [(4, 1), (7, 1), (10, 1), (11, 1)]
# ]

実行結果は上記のようになります。

このように各単語のIDとその出現回数をペアで示した単純な形式に変換されました。[(8, 2), (9, 1)]と表現されたテキストデータがありますが、これはID8の単語が2回、ID9の単語が1回出現することを意味します。そして、実際の単語はdictionaryオブジェクトに保存されています。

この処理により、単語の出現回数だけが抽出されます。つまり、単語の順番や文脈といった情報は残りません。

以前、Embeddingを使用してテキストデータをベクトル形式に変換したことがありますが、そのときは人間が直接理解できない形式(小数点数の配列)だったので、それに比べると変換結果が確認しやすいです。

LDAによるトピックモデリング

LDA(潜在ディリクレ配分)モデルを利用して、さきほど生成したコーパス(文書集合)からトピックを抽出します。ここでは、各テキストデータがどのトピックにどの程度属しているかを定量的に示すところまで行っています。

今回はnum_topics=2として、トピック数を2に指定しました。以下のように、「スタッフ」「部屋」などの単語がメインで構成されるトピックと「オムライス」「料理」などの単語がメインで構成されるトピックの2つが作られたのがわかります。

import pandas as pd
from gensim import models

# 訓練
lda_model = models.LdaModel(corpus, num_topics=2, id2word=dictionary, passes=15)

topic_details = lda_model.show_topics(num_topics=2, num_words=8, formatted=False)
topics_data = []
for topic_num, topic_words in topic_details:
    for word, prob in topic_words:
        topics_data.append([topic_num, word, prob])

topics_df = pd.DataFrame(topics_data, columns=['Topic', 'Word', 'Probability'])
index Topic Word Probability
0 0 スタッフ 0.158006489276886
1 0 部屋 0.15714037418365479
2 0 対応 0.09462684392929077
3 0 食事 0.09462665766477585
4 0 親切 0.09453891962766647
5 0 平凡 0.09346158802509308
6 0 快適 0.09311409294605255
7 0 料理 0.08584534376859665
8 1 オムライス 0.2035583257675171
9 1 料理 0.1345314234495163
10 1 0.12196376174688339
11 1 期待 0.12192101031541824
12 1 ロケーション 0.1219123974442482
13 1 快適 0.043540917336940765
14 1 平凡 0.04309404268860817
15 1 部屋 0.042846012860536575

続いて、各テキストデータがどのトピックにどの程度属しているかを示す確率を取得します。get_document_topicsという関数で以下のように取得することができます。

doc_topics = lda_model.get_document_topics(corpus, minimum_probability=0.0)

doc_topics_data = []
for doc_num, topics in enumerate(doc_topics):
    for topic_num, prob in topics:
        doc_topics_data.append([doc_num, topic_num, prob])

docs_df = pd.DataFrame(doc_topics_data, columns=['Document', 'Topic', 'Probability'])
index Document Topic Probability
0 0 0 0.8688803315162659
1 0 1 0.13111963868141174
2 1 0 0.13640905916690826
3 1 1 0.8635908961296082
4 2 0 0.8700626492500305
5 2 1 0.12993738055229187
6 3 0 0.12769751250743866
7 3 1 0.8723024725914001
8 4 0 0.8750449419021606
9 4 1 0.12495504319667816

これをわかりやすく整形したものが以下です。トピックごとに出現頻度の高い単語を3つ選び、それをラベルとして使用しています。トピックモデルでは各トピックの持つ意味まではわからないので、このように人間が解釈を加える必要があります。

topic_labels = topics_df.groupby('Topic').apply(lambda x: ', '.join(x.sort_values('Probability', ascending=False)['Word'].head(3)))
topic_label_dict = topic_labels.to_dict()
max_prob_idx = docs_df.groupby('Document')['Probability'].idxmax()
dominant_topic_per_doc = docs_df.loc[max_prob_idx].reset_index(drop=True)
dominant_topic_per_doc['Text'] = dominant_topic_per_doc['Document'].apply(lambda x: text_samples[x])
dominant_topic_per_doc['TopicLabel'] = dominant_topic_per_doc['Topic'].map(topic_label_dict)

dominant_topic_per_doc[['Text', 'TopicLabel', 'Probability']]
index Text TopicLabel Probability
0 食事が美味しく、スタッフの対応も良かった。 スタッフ, 部屋, 対応 0.8688803315162659
1 ロケーションは良いが、料理が期待ほどではなかった。 オムライス, 料理, 今 0.8635908961296082
2 スタッフは親切だが、部屋が狭すぎる。 スタッフ, 部屋, 対応 0.8700626492500305
3 今までのオムライスで一番美味しいオムライスだった。 オムライス, 料理, 今 0.8723024725914001
4 部屋は広くて快適だったが、料理が平凡だった。 スタッフ, 部屋, 対応 0.8750449419021606

以上がLDAによるトピック抽出でした。

WordCloudによる可視化

ここからはおまけで、WordCloudでトピックモデリングの結果を可視化していきます。

WordCloudは、そのトピックにおける単語の重要度(出現確率)を単語のサイズで表現するもので、トピックの構成を視覚的に理解するのに役立ちます。トピックモデリングの結果を、専門外の誰かに共有して議論したいときなどにも役立ちそうです。

そのままでは日本語が表示されなかったので、日本語フォントをインストールしています。

# 日本語表示に対応するためフォントをインストール
!apt-get -y install fonts-ipafont-gothic

from wordcloud import WordCloud
import matplotlib.pyplot as plt

font_path = '/usr/share/fonts/opentype/ipafont-gothic/ipagp.ttf'
for topic_num in topics_df['Topic'].unique():
    topic_words = topics_df[topics_df['Topic'] == topic_num]
    word_freqs = dict(zip(topic_words['Word'], topic_words['Probability']))
    wordcloud = WordCloud(font_path=font_path, width=400, height=300, background_color='white').generate_from_frequencies(word_freqs)
    
    plt.figure(figsize=(10, 8))
    plt.imshow(wordcloud, interpolation='bilinear')
    plt.axis('off')
    plt.title(f'Topic {topic_num}')
    plt.show()

感想

トピックモデルを試した所感です。注意すべき点が2つあると感じました。

まず、単語の意味的な関連は無視されることは認識しておくべきだと思います。たとえば、「学校」と「教育」という単語は、同じトピックに関連している可能性が高いですが、それらが異なるテキストで使用されている場合、LDAは別のトピックとして処理してしまう可能性があります。

また、単語の出現回数を基にトピックを抽出するため、文脈が無視されてしまう点も注意すべきだと思いました。たとえば、「ひかり」という単語が新幹線を示すのか、物理現象なのかは区別しません。まったく別の話をしていても同一のトピックと解釈されてしまう可能性があります。

こういった点を考慮すると、やはりGPTなどのLLMを利用してセンテンスごとベクトル化するなどのアプローチのほうが正確なのかな、という気がしました。

Discussion