Pythonでトピックモデル Word Cloud と LDA

スポンサーリンク

 
SNSがコミュニケーションのインフラになりつつあることで、世の中は言葉で溢れています。この膨大な言葉の文章をまとめることで一つ一つの文章からはわからない傾向を新たに獲得することができます。具体的には、文章をカテゴライズして分類することで、どのカテゴリが人気なのかがわかったりします。これは機械学習の分類問題としてよく扱われていますが、重要な前提として「各文章は一つのカテゴリに属す」としています。しかしながら、いくつかのトピックが含まれている文章は多々あります。ファミレスでよく聞く井戸端談義はトピックだらけです。そこで、一つのカテゴリに分類するのではなく、分類に重要な単語(トピック)の重み付けで分類するようにしたのが、トピックモデルです。


今回は、文章の傾向を出現頻度トピック抽出により理解していきます。そのために、単語の出現頻度をインパクトのある可視化をするWord Cloudと各文章をトピック分類するLatent Dirichlet Allocation(以下、LDA)を使います。以下は今回行ったことの結果です。左の図は学問のすすめのWord Cloudを作成したものであり、右はアニメを含んだtweetのLDAによるトピックを求めた結果です。今回使用したコードはgithubに置いております。


f:id:dskomei:20180409233434p:plain:w250 f:id:dskomei:20180410224212p:plain:w250


このような言語関連の分析をする上で扱うデータを揃えるのは大変です。言語データを入手するために、あれやこれやと試行錯誤をしないといけないため、本来やりたい分析になかなか入れなかったりします。言語データを整理する作業にはあまり時間をかけたくないのが本音です。しかし、言語データの分析は前処理次第で結果が大きく変わるため、前処理を雑にやると良い分析ができません。フライドチキンの下味と一緒です。なので、今回は、言語データを集めて前処理をする部分に関しても触れていきたいと思います。



言語データを簡単に取得


トピックモデルを早速試していきたいのですが、言語データがないと何もできません。そこでまず、簡単に入手可能な言語データを使って一通り試してみます。簡単に入手可能な言語データとして、青空文庫のオンラインの書き起こしデータがあります。ここでは青空文庫の本が電子化されており、自由にダウンロードすることができます。今回は昔読んで感銘を受けた福沢諭吉さんの「学問のすすめ」をスクレイピングして、分析に使いたいと思います。


福沢諭吉さんの学問のすすめをスクレイピングして入手するにあたって、pyqueryライブラリを使用しています。スクレイピングのライブラリとしてはBeautifulsoupが有名ですが、pyqueryも使いやすいです。pyqueryの使い方はこちらがわかりやすかったです。青空文庫では、メインの内容はdivタグの『main_text』内に入っています。指定したurlからhtmlのテキストを取得し、学問のすすめの内容が記載されている部分を抜き取り、文ごとに分けて保存しています。この一連の処理は以下のとおりです。

from pathlib import Path
import urllib.request
from pyquery import PyQuery as pq

"""
青空文庫から「学問のすすめ」の文章を取得する

"""

data_dir_path = Path('./data')

url = 'https://www.aozora.gr.jp/cards/000296/files/47061_29420.html'
with urllib.request.urlopen(url) as response:
    html = response.read()
query = pq(html,  parser='html')

text = query(".main_text").text().replace('\n', '')
texts = text.split('。')
texts = [text_+'\n' for text_ in texts]

with open(data_dir_path.joinpath('gakumon_no_susume.txt'), 'w', encoding='utf-8') as file:
    file.writelines(texts)



文の要素の抽出 形態素解析


それでは抽出した文をいくつか見てみましょう。

初編「天は人の上に人を造らず人の下に人を造らず」と言えり
されば天より人を生ずるには、万人は万人みな同じ位にして、生まれながら貴賤(きせん)上下の差別なく、万物の霊たる身と心との働きをもって天地の間にあるよろずの物を資(と)り、もって衣食住の用を達し、自由自在、互いに人の妨げをなさずしておのおの安楽にこの世を渡らしめ給うの趣意なり
されども今、広くこの人間世界を見渡すに、かしこき人あり、おろかなる人あり、貧しきもあり、富めるもあり、貴人もあり、下人もありて、その有様雲と泥(どろ)との相違あるに似たるはなんぞや


やはり格式高い文章で自分とは住む世界が違うのではないかと思ってしまいます。それはさておき、それぞれの文を何らかのカテゴリに分けることを考えるのですが、文をそのままいくつか見てもどのようなカテゴリに分類をすればいいのかなかなかわかりません。そこで、文に含まれている単語の頻度を利用してカテゴリに分けることを考えてみます。つまり、よく出現する単語は重要であろうということです。単語の出現頻度を求めたいので、まず文を単語に分けなければいけません。更に、単語に分けカテゴリ分類する際には情報として無駄な助詞とか記号などは消し去りたいです。また動詞の場合、活用形によって形が変わってしまうので、同じ意味のものは統一した一つの単語として扱いたいです。このように、言語データを分析する上では前処理が欠かせません。この処理を一から書くとなると骨が折れますが、モジュール「janome」を使うことで、簡単に上記のことができます。言語データの前処理に関して下記にまとめます。

  1. 文から不要な文字を削除
  2. 文を単語ごとに分割
  3. 単語を正規化し、必要な単語のみを取得
  4. 単語の原型に変換


下図は上記の1~4を学問のすすめに対して行った結果のうちのはじめの一文を載せています。


f:id:dskomei:20180409230108p:plain:w400


図を見て分かる通り、学問のすすめの最初の一文に対して、単語に分割し、助詞を削除し、単語を原型に変換できていることがわかります。この処理に関するコードを以下に記載します。

from pathlib import Path
from janome.charfilter import *
from janome.analyzer import Analyzer
from janome.tokenizer import Tokenizer
from janome.tokenfilter import *
from gensim import corpora


data_dir_path = Path('./data')
corpus_dir_path = Path('./corpus')


file_name = 'gakumon_no_susume.txt'

with open(data_dir_path.joinpath(file_name), 'r', encoding='utf-8') as file:
    texts = file.readlines()
texts = [text_.replace('\n', '') for text_ in texts]


# janomeのAnalyzerを使うことで、文の分割と単語の正規化をまとめて行うことができる
# 文に対する処理のまとめ
char_filters = [UnicodeNormalizeCharFilter(),         # UnicodeをNFKC(デフォルト)で正規化
                RegexReplaceCharFilter('\(', ''),     # (を削除
                RegexReplaceCharFilter('\)', '')      # )を削除
                ]

# 単語に分割
tokenizer = Tokenizer()


#
# 名詞中の数(漢数字を含む)を全て0に置き換えるTokenFilterの実装
#
class NumericReplaceFilter(TokenFilter):

    def apply(self, tokens):
        for token in tokens:
            parts = token.part_of_speech.split(',')
            if (parts[0] == '名詞' and parts[1] == '数'):
                token.surface = '0'
                token.base_form = '0'
                token.reading = 'ゼロ'
                token.phonetic = 'ゼロ'
            yield token


#
#  ひらがな・カタガナ・英数字の一文字しか無い単語は削除
#
class OneCharacterReplaceFilter(TokenFilter):

    def apply(self, tokens):
        for token in tokens:
            # 上記のルールの一文字制限で引っかかった場合、その単語を無視
            if re.match('^[あ-んア-ンa-zA-Z0-9ー]$', token.surface):
                continue

            yield token


# 単語に対する処理のまとめ
token_filters = [NumericReplaceFilter(),                         # 名詞中の漢数字を含む数字を0に置換
                 CompoundNounFilter(),                           # 名詞が連続する場合は複合名詞にする
                 POSKeepFilter(['名詞', '動詞', '形容詞', '副詞']),# 名詞・動詞・形容詞・副詞のみを取得する
                 LowerCaseFilter(),                              # 英字は小文字にする
                 OneCharacterReplaceFilter()                     # 一文字しか無いひらがなとカタガナと英数字は削除
                 ]

analyzer = Analyzer(char_filters, tokenizer, token_filters)


tokens_list = []
raw_texts = []
for text in texts:
    # 文を分割し、単語をそれぞれ正規化する
    text_ = [token.base_form for token in analyzer.analyze(text)]
    if len(text_) > 0:
        tokens_list.append([token.base_form for token in analyzer.analyze(text)])
        raw_texts.append(text)

# 正規化された際に一文字もない文の削除後の元テキストデータ
raw_texts = [text_+'\n' for text_ in raw_texts]
with open(data_dir_path.joinpath(file_name.replace('.txt', '_cut.txt')), 'w', encoding='utf-8') as file:
    file.writelines(raw_texts)

# 単語リストの作成
words = []
for text in tokens_list:
    words.extend([word+'\n' for word in text if word != ''])
with open(corpus_dir_path.joinpath(file_name.replace('.txt', '_word_list.txt')), 'w', encoding='utf-8') as file:
    file.writelines(words)



word cloud 単語の出現の頻度の可視化


文をうまいこと単語に分けることができたので、トピックを抽出していきたいのですが、その前にこのドキュメントにはどのような単語が多いのかの全体感をまず知りたいですよね。本当に言いたいことは何だったのかを。そこで、単語の出現頻度の可視化をまずしてみます。ただ、単純に出現頻度が多い順に単語を並べても芸がないので、可視化としてインパクトのある「Word Cloud」を使います。これは単語の出現頻度に応じて単語のサイズを大きくし、場所と角度をランダムにして表示することで、どの単語がよく使われているかをインパクトのある絵で確認できます。Word Cloudはamuellerさんがpythonでライブラリを作ってくださっています。インストールなどに関してはこちらが参考になります。それでは先ほど作成した学問のすすめの単語リストから作成したWord Cloudを見てみましょう。


f:id:dskomei:20180409233434p:plain:w350


学問のすすめでは有名な一文がありますが、Word Cloudを見ると、目立つのは「政府」、「知る」、「独立」といった単語です。昔読んだ記憶では、学問に励むことで自立することの大切さを唱えているのが主旨だったと記憶しているので、その点を表せられているのではないかと思います。それでは、このコードに関して以下に記載します。

from pathlib import Path
from wordcloud import WordCloud
import matplotlib.pyplot as plt


image_dir_path = Path('./image')
corpus_dir_path = Path('./corpus')

if not image_dir_path.exists():
    image_dir_path.mkdir(parents=True)

file_name = 'gakumon_no_susume_word_list.txt'

with open(corpus_dir_path.joinpath(file_name), 'r', encoding='utf-8') as file:
    text = file.readlines()
text = ' '.join(text).replace('\n', '')


# 日本語が使えるように日本語フォントの設定
fpath = 'C:\Windows\Fonts\ipaexg.ttf'


# ストップワードの設定
# ここで設定した単語はWord Cloudに表示されない
stop_words = [u'てる', u'いる', u'なる', u'れる', u'する', u'ある', u'こと', u'これ', u'さん', u'して', \
              u'くれる', u'やる', u'くださる', u'そう', u'せる', u'した', u'思う', \
              u'それ', u'ここ', u'ちゃん', u'くん', u'', u'て', u'に', u'を', u'は', u'の', u'が', u'と', u'た', u'し', u'で', \
              u'ない', u'も', u'な', u'い', u'か', u'ので', u'よう', u'', u'もの', u'もつ']

wordcloud = WordCloud(background_color="white",
                      font_path=fpath,
                      width=900,
                      height=500,
                      stopwords=set(stop_words)).generate(text)

plt.figure(figsize=(10, 8))
plt.imshow(wordcloud)
plt.axis("off")
plt.tight_layout()
plt.savefig(image_dir_path.joinpath(file_name.replace('list.txt', 'cloud.png')).__str__())
plt.show()



言語データの交代


先程、Word Cloudを作って学問のすすめの全体感を見たので、トピックモデルを使ってトピック分類をしていこうという流れになりますが、せっかくなのでデータを替えてみたいと思います。引き伸ばしているわけではありません汗)


様々な人の趣味趣向に関する言語データを使ってみてみたいと思います。そこで、個人的に興味がある「最近のアニメに関して」というテーマで言語データを集めてみました。とは言っても、twitterからアニメと言う単語を含んだツイートを1万件抽出しただけです。twitterからのデータの取得に関しては、今回は記載しませんが、こちらに詳しく書かれています。アニメツイートデータに対して学問のすすめのときに行ったような前処理をし、単語リストを作ると共に、トピックモデルによる分析のための文章のベクトル化も行っています。ここから単語リストを落とせます。文章のベクトル化の具体例としては、次の図のように同じ単語には同じインデックスを付与していくことで数値ベクトルに変換しています。


f:id:dskomei:20180410232127p:plain:w400


このアニメツイートデータに対するWord Cloudは次の通りです。左の図は、学問のすすめのときにやった前処理に加えて、記号などを新たに削除した単語リストで作成したものです。動詞の大きさが目立つかと思います。そこで、名詞だけに絞った単語リストから作成したのが右の図です。若干動詞がまだ含まれていますが、先程よりは各ツイートの内容が想像できそうな気がします。ゲームとアニメがタイアップしているのか、放送を心待ちにしているのかなど。


f:id:dskomei:20180410232720p:plain:w250 f:id:dskomei:20180410232738p:plain:w250


ツイートアニメの前処理のコードは以下の通りです。学問のすすめの際と変更した点は、様々な記号を消すようにしたことと、LDA用にコーパスを作成したことです。

from pathlib import Path
from janome.charfilter import *
from janome.analyzer import Analyzer
from janome.tokenizer import Tokenizer
from janome.tokenfilter import *
from gensim import corpora
import re



data_dir_path = Path('./data')
corpus_dir_path = Path('./corpus')
if not corpus_dir_path.exists():
    corpus_dir_path.mkdir(parents=True)


file_name = 'tweet_anime.txt'
with open(data_dir_path.joinpath(file_name), 'r', encoding='utf-8') as file:
    texts = file.readlines()

texts = [text.replace('\n', '') for text in texts]



# janomeのAnalyzerを使うことで、文の分割と単語の正規化をまとめて行うことができる
# 文に対する処理のまとめ
char_filters = [UnicodeNormalizeCharFilter(),                               # UnicodeをNFKC(デフォルト)で正規化
                RegexReplaceCharFilter('http[a-z!-/:-@[-`{-~]', ''),        # urlの削除
                RegexReplaceCharFilter('@[a-zA-Z]+', ''),                   # @ユーザ名の削除
                RegexReplaceCharFilter('[!-/:-@[-`{-~♪♫♣♂✨дд∴∀♡☺➡〃∩∧⊂⌒゚≪≫•°。、♥❤◝◜◉◉★☆✊≡ø彡「」『』○≦∇✿╹◡✌]', ''), # 記号の削除
                RegexReplaceCharFilter('\u200b', ''),                       # 空白の削除
                RegexReplaceCharFilter('アニメ', '')                         # 検索キーワードの削除
                ]

tokenizer = Tokenizer()


#
# 名詞中の数(漢数字を含む)を全て0に置き換えるTokenFilterの実装
#
class NumericReplaceFilter(TokenFilter):
    """
    名詞中の数(漢数字を含む)を全て0に置き換えるTokenFilterの実装
    """
    def apply(self, tokens):
        for token in tokens:
            parts = token.part_of_speech.split(',')
            if (parts[0] == '名詞' and parts[1] == '数'):
                token.surface = '0'
                token.base_form = '0'
                token.reading = 'ゼロ'
                token.phonetic = 'ゼロ'

            yield token

#
#  ひらがな・カタガナ・英数字の一文字しか無い単語は削除
#
class OneCharacterReplaceFilter(TokenFilter):

    def apply(self, tokens):
        for token in tokens:
            # 上記のルールの一文字制限で引っかかった場合、その単語を無視
            if re.match('^[あ-んア-ンa-zA-Z0-9ー]$', token.surface):
                continue

            if re.match('^w+$', token.surface):
                continue

            if re.match('^ー+$', token.surface):
                continue

            yield token


token_filters = [NumericReplaceFilter(),       # 名詞中の漢数字を含む数字を0に置換
                 CompoundNounFilter(),         # 名詞が連続する場合は複合名詞にする
                 POSKeepFilter(['名詞']),      # 名詞・動詞・形容詞・副詞のみを取得する
                 LowerCaseFilter(),            # 英字は小文字にする
                 OneCharacterReplaceFilter(),
                 ] 

analyzer = Analyzer(char_filters, tokenizer, token_filters)


tokens_list = []
raw_texts = []
for text in texts:
    text_ = [token.base_form for token in analyzer.analyze(text)]
    if len(text_) > 0:
        tokens_list.append([token.base_form for token in analyzer.analyze(text)])
        raw_texts.append(text)


# 正規化された際に一文字もない文の削除後の元テキストデータ
raw_texts = [text_+'\n' for text_ in raw_texts]
with open(data_dir_path.joinpath(file_name.replace('.txt', '_cut.txt')), 'w', encoding='utf-8') as file:
    file.writelines(raw_texts)

# 単語リストの作成
words = []
for text in tokens_list:
    words.extend([word+'\n' for word in text if word != ''])
with open(corpus_dir_path.joinpath(file_name.replace('.txt', '_word_list.txt')), 'w', encoding='utf-8') as file:
    file.writelines(words)
    

#
# 単語のインデックス化
#
dictionary = corpora.Dictionary(tokens_list)
# 単語の出現回数が1以下か、文を通しての出現割合が6割を超えているものは削除
dictionary.filter_extremes(no_below=1, no_above=0.6)
dictionary.save_as_text(corpus_dir_path.joinpath(file_name.replace('.txt', '_dictionary.dict.txt')).__str__())

#
# 文の数値ベクトル化
#
corpus = [dictionary.doc2bow(tokens) for tokens in tokens_list]
corpora.MmCorpus.serialize(corpus_dir_path.joinpath(file_name.replace('.txt','_corpus.mm')).__str__(), corpus)



潜在的ディリクレイ配分法 LDA


単語の出現度合いの全体感を俯瞰したことで、どのようなツイートがあるのかなんとなくわかってきたような気がします。それでは、各文書をトピック分類していきます。そのためには、潜在的ディリクレイ配分法を使いますが、潜在的ディリクレイ配分法はどういう原理なのかに関しては、数式がゴリゴリ出てくるので今回は割愛します。気になる方がぜひこちらをご覧ください。


それでは、先程作った名詞のみのツイートアニメデータのトピック分類をしてみます。LDA自体は一行でできてしまいます。下記のコードでは、トピックを50個にして学習しています。

from pathlib import Path
import gensim


"""
LDAのモデルを作る

"""

corpus_dir_path = Path('./corpus')
model_dir_path = Path('./model')
if not model_dir_path.exists():
    model_dir_path.mkdir(parents=True)


file_name = 'tweet_anime.txt'
dictionary = gensim.corpora.Dictionary.load_from_text(corpus_dir_path.joinpath(file_name.replace('.txt', '_dictionary.dict.txt')).__str__())
corpus = gensim.corpora.MmCorpus(corpus_dir_path.joinpath(file_name.replace('.txt', '_corpus.mm')).__str__())


# トピック数50のLDAを作る
model = gensim.models.LdaModel(corpus,
                               num_topics=50,
                               id2word=dictionary)

model.save(model_dir_path.joinpath(file_name.replace('.txt', '_lda.pkl')).__str__())


LDAにより分類した結果を見てみたいと思います。下記の例は、ある文とその文に対する推測のトピックの番号とその重みを示しています。そして、2つ目の文章は、1つ目の文章とのコサイン類似度が最も近いと測定されたものを示しています。両方共主旨は異なりますが、お願いしているという点では同じなのでしょうか・・・。

ダ・カーポが好きな人はフォローよろしくです!アニメはⅠ、Ⅲ(Ⅱは今後見る予定)見てます。ゲームはIIIプラスのみプレイしてます。
[(31, 0.45358875), (41, 0.27089253), (10, 0.096332), (44, 0.09555033)]
オススメのアニメあったら教えてくださいねっ♪ #【定期】
[(31, 0.34243003), (41, 0.33756995)]


今回は50個のトピックにして分類しましたが、そのうちの10個を見てみたいと思います。行が各トピックを表しており、列が各トピックの中での重要な単語です。左の列ほど重要度が増します。トピック1は女性のキャラに関して述べている文章が集まっているのではないかと推測されます。


f:id:dskomei:20180410224212p:plain:w500


先程は最も類似度が高い文章を抽出しましたが、それができたのはトピックの重みをベクトル化することで、文章をベクトル化できたからです。そして、それは新しい文章に関してもできます。トピックモデルを使うことで学習に使っていない文章の分類も可能です。
 

LDAを使った分析の一連の流れは以下の通りです。

from pathlib import Path
import gensim
import numpy as np
from scipy.spatial import distance
from sklearn.metrics.pairwise import cosine_similarity
import pandas as pd


"""
LDAモデルを使ってトピック分類をする

"""


corpus_dir_path = Path('./corpus')
model_dir_path = Path('./model')
data_dir_path = Path('./data')
result_dir_path = Path('./result')


if not result_dir_path.exists():
    result_dir_path.mkdir(parents=True)


file_name = 'tweet_anime.txt'
with open(data_dir_path.joinpath(file_name.replace('.txt', '_cut.txt')), 'r', encoding='utf-8') as file:
    texts = file.readlines()
texts = [text.replace('\n', '') for text in texts]


dictionary = gensim.corpora.Dictionary.load_from_text(corpus_dir_path.joinpath(file_name.replace('.txt', '_dictionary.dict.txt')).__str__())
corpus = gensim.corpora.MmCorpus(corpus_dir_path.joinpath(file_name.replace('.txt', '_corpus.mm')).__str__())
model = gensim.models.ldamodel.LdaModel.load(model_dir_path.joinpath(file_name.replace('.txt', '_lda.pkl')).__str__())


# 文章ごとのトピック分類結果を得る
topics = [model[c] for c in corpus]

def sort_(x):
    return sorted(x, key=lambda x_:x_[1], reverse=True)

# トピック数を取得
num_topics = model.get_topics().shape[0]

# 文章間の類似度を測定するためにコサイン類似度の行列を作成
# 各文章を行として、トピックの重みを格納
dences = np.zeros((len(topics), num_topics), dtype=np.float)
for row, t_ in enumerate(topics):
    for col, value in t_:
        dences[row, col] = value

# 文章間のコサイン類似度の計算
cosine_ = cosine_similarity(dences, dences)
for i in range(cosine_.shape[0]):
    # 対角成分は類似度が1となるため、0に修正
    cosine_[i, i] = 0

#
# 文章の比較
#
target_doc_id = 4
print(texts[target_doc_id])
print(sort_(topics[target_doc_id]))

print(texts[int(np.argmax(cosine_[target_doc_id]))])
print(sort_(topics[int(np.argmax(cosine_[target_doc_id]))]))

#
# 各トピックの要素の表示
#
topic10 = []
for topic_ in model.show_topics(-1, formatted=False):
    topic10.append([token_[0] for token_ in topic_[1]])

topic10 = pd.DataFrame(topic10)
print(topic10)
topic10.to_csv(result_dir_path.joinpath(file_name.replace('.txt', '_topic10.csv')).__str__(),
               index=False, encoding='utf-8')



参考文献


今回は下記の本を参考にさせていただきました。今回は取り上げられなかったですが、LDAにおけるトピック数の自動決定に関しても触れられています。