LDAでブログ記事のトピックを抽出・分類する

今回はLDAを使って、京大ブログコーパスをトピック毎に分類できないか試みてみます。

LDA

LDA(Latent Dirichlet Allocation, 潜在ディリクレ配分法)は、文書のトピック(文書の話題、カテゴリ、ジャンルとも言える)についてのモデルです。 初出は以下の論文です。

http://jmlr.csail.mit.edu/papers/v3/blei03a.html

LDAでは、文書のトピックは単一に定まるのではなく、複数のトピックの割合で表現できると考えます。 例えば、「AlphaGo」に関するある記事は、「囲碁」トピック60%、「人工知能」トピック40%といった感じです。 トピックは未知ですが、この確率分布はディリクレ分布に従って生成されると仮定します。
文書を構成する各単語は、この潜在的なトピックから生成されたと考えます。 AlphaGoの記事を再度引き合いに出すと、例えば「19路盤」という単語は囲碁トピック95%、「ニューラルネットワーク」なら人工知能トピック80%といった具合です。 つまり、共通する単語を多く持つ2つの文書は、トピックも重複していると考えることができます。

LDAでは、各単語のトピック(確率分布)を推定し、さらに単語の集合である文書のトピックを推定します。 ただし、教師なし学習のため、ラベリングされるわけではないことに注意してください。 またトピック数もハイパーパラメータとして事前に設定する必要があります。

LDAについては以下が参考になります。

abicky.net

LDA入門

LDAの実装として、今回もgensimを使います。

radimrehurek.com

ニュース記事から単語を抽出

前回の投稿でも使った京大ブログコーパスからトピック抽出します。

http://nlp.ist.i.kyoto-u.ac.jp/kuntt/

リンクから、"解析済みブログコーパス"(KNBC_v1.0_090925.tar.bz2)をダウンロード・解凍し、得られたディレクトリ(KNBC_v1.0_090925)をこれから作成するPythonファイルと同じディレクトリにコピーします。

KNBC_v1.0_090925/corpus2ディレクトリ以下には4つのカテゴリ(グルメ、携帯電話、京都観光、スポーツ)毎にTSVファイルが存在します。
グルメ(Gourmet.tsv)が57記事、携帯電話(Keitai.tsv)が79記事、京都観光(Kyoto.tsv)が91記事、スポーツ(Sports.tsv)が22記事で、合計249記事が収録されています。

import glob
import re


# ブログコーパスのリスト
texts = {}

# livedoorニュースのファイル名一覧を取得する
paths = glob.glob('./KNBC_v1.0_090925/corpus2/*.tsv')

for path in paths:
    with open(path, 'r', encoding='euc-jp') as f:
        tsv = f.read()
        
        for i, line in enumerate(tsv.split('\n')):
            if line == '':
                break

            # 1行1文となっており、各行は以下のようにタブ区切りされている
            # KN203_Kyoto_1-1-2-01 最近、鴨川沿いを歩くことにはまってます☆    [著者]    鴨川沿いを歩くことにはまってます    採否+   鴨川沿いを歩くこと
            columns = line.split('\t')
            # 各行の1番目のカラムには記事IDが付与されているので、記事ID毎に本文(2番目のカラム)を連結する
            # なお3番目以降のカラムはアノテーションで、今回は使用しないため、読み捨てる
            index = columns[0].split('-')[0]
            if not index in texts:
                texts[index] = ''
                # 1行目はタイトルで、"[京都観光]"などの分類を表す言葉が入っているので、読み捨てる
                continue
            texts[index] = texts[index] + columns[1]

print('texts length: {}'.format(len(texts)) )
# texts length: 249
print('text(KN203_Kyoto_1): \n{}'.format(texts['KN203_Kyoto_1']))
# text(KN203_Kyoto_1): 
# 最近、鴨川沿いを歩くことにはまってます☆鴨川沿いを歩くのはほんとに気持ちよくて、京都はいいところだなぁとしみじみ感じています。虫がたまに口の中に入るのが難点なのですが・・でも、最近歩きすぎて、なんだか足に筋肉がついてきてる気が!?歩きすぎには要注意です。とかいいつつ、今日もいい天気なので、時間をみつけていってこようと思います☆

形態素解析

今回もNEologd付きのjanomeで形態素解析し、単語ごとに分かち書きします。 NEologd付きのjanomeのインストールは本家のwiki前回の投稿を参考にしてみてください。

from janome.tokenizer import Tokenizer
from janome.charfilter import UnicodeNormalizeCharFilter, RegexReplaceCharFilter
from janome.tokenfilter import POSKeepFilter, LowerCaseFilter, ExtractAttributeFilter
from janome.analyzer import Analyzer

            
char_filters = [UnicodeNormalizeCharFilter(), # UnicodeをNFKCで正規化
                RegexReplaceCharFilter('\d+', '0')] # 数字を全て0に置換

tokenizer = Tokenizer(mmap=True) # NEologdを使う場合、mmap=Trueとする

token_filters = [POSKeepFilter(['名詞', '形容詞', '副詞', '動詞']), # 名詞、形容詞、副詞、動詞のみを抽出する
                 LowerCaseFilter(), # 英字は小文字にする
                 ExtractAttributeFilter('base_form')] # 原型のみを取得する

analyzer = Analyzer(char_filters, tokenizer, token_filters)

単語の抽出とストップワードの除去

先程作成したAnalyzerで分かち書きを行い、最後にストップワードを除去します。

今回は、Slothlibのストップワードを使います。
これは「あそこ」「こちら」「私」「多く」「確か」などの日本語のほとんどの文書に現れる単語のリストで、こういった単語はカテゴリ抽出の役に立たないので除外します。

上記の結果、249記事から28104単語を抽出しました。

import urllib.request


stopwords = []
url = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'

# ストップワードの取得
with urllib.request.urlopen(url) as response:
    stopwords = [w for w in response.read().decode().split('\r\n') if w != '']

print('Stopwords: {}'.format(stopwords))
# Stopwords: ['あそこ', 'あたり', 'あちら', 'あっち', 'あと', ...

texts_words = {}

for k, v in texts.items():
    texts_words[k] = [w for w in analyzer.analyze(v)]

print('words count: {}'.format(sum([len(t) for t in texts_words.values()])))
# words count: 28104

辞書とコーパスを作る

gensimのLDAでは、事前に辞書とコーパスを作る必要があります。

  • 辞書は、単語とその単語を一意に識別するIDを持ち、gensim.corpora.Dictionaryがその実装となります
    • Dictionary.filter.extremesで辞書に登録する単語に制限を設けられます
      • no_below: 出現する文書数が指定数未満の単語は除外(今回は3未満)
      • no_above: 出現する文書の割合が指定数以上の単語は除外(今回は40%以上)
  • コーパスは、文書毎の単語IDとその出現回数を持つタプルリストで、いわゆるBag of Words(BoW)です
# pip install gensim
import gensim


# 辞書の作成
dictionary = gensim.corpora.Dictionary(texts_words.values())
dictionary.filter_extremes(no_below=3, no_above=0.4)
# 辞書をテキストファイルで保存する場合
# dictionary.save_as_text('blog_dictionary.txt')
# dictionary = gensim.corpora.Dictionary.load_from_text('blog_dictionary.txt')

print('dictionary: {}'.format(dictionary.token2id))
# dictionary: {'行く': 0, 'くる': 1, 'おいしい': 2, '庭': 3, '町屋': 4, '風': 5, '店内': 6, ...

# コーパスの作成(ベクトル化)
corpus = [dictionary.doc2bow(words) for words in texts_words.values()]
# コーパスをテキストファイルで保存する場合
# gensim.corpora.MmCorpus.serialize('blog_corpus.mm', corpus)
# corpus = gensim.corpora.MmCorpus('blog_corpus.mm')

print('corpus: {}'.format(corpus))
# corpus: [[(0, 2), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1), ...

モデルを構築

最後にLDAでモデルを構築して、文書毎のトピックを分析してみます。

gensim.models.ldamodel.LdaModelを使っています。

  • corpusid2wordに、先程作成したコーパスと辞書をセットします
  • num_topics: トピック数を設定します
    • 元々4つのカテゴリに分けられた文書のため、4を設定します

show_topicsメソッドでトピック毎に出現確率が高い単語順に出力できます。

  • トピック0、2、3は類似しており、京都観光とグルメが当てはまりそうな単語が並んでいます
  • トピック1は、携帯電話に関するもののようです
# LDAモデルの構築
lda = gensim.models.ldamodel.LdaModel(corpus=corpus, 
                                      num_topics=4, 
                                      id2word=dictionary, 
                                      random_state=1)

print('topics: {}'.format(lda.show_topics()))
[(0,
  '0.022*"京都" + 0.014*"行く" + 0.011*"食べる" + 0.009*"くる" + 0.008*"みる" + 0.007*"見る" + 0.007*"ん" + 0.007*"できる" + 0.007*"いう" + 0.006*"いい"'),
 (1,
  '0.024*"携帯" + 0.020*"携帯電話" + 0.014*"使う" + 0.012*"メール" + 0.011*"しまう" + 0.010*"くる" + 0.009*"いう" + 0.008*"できる" + 0.008*"電話" + 0.008*"みる"'),
 (2,
  '0.011*"行く" + 0.011*"できる" + 0.010*"京都" + 0.010*"てる" + 0.010*"ん" + 0.009*"使う" + 0.009*"メール" + 0.009*"食べる" + 0.009*"いい" + 0.009*"見る"'),
 (3,
  '0.014*"しまう" + 0.013*"京都" + 0.011*"できる" + 0.009*"見る" + 0.009*"いい" + 0.009*"くる" + 0.008*"行く" + 0.008*"携帯電話" + 0.008*"みる" + 0.008*"ん"')]

トピックを分析

最後に文書毎のトピックを分析してみます。

LdaModel.get_document_topicsメソッドで、文書のBoWからトピックに属する確率をタプルリストで取得できます。 確率が最大となるトピックIDをその文書のトピックとして分類しました。

topic_counts = {
    'Kyoto': [0, 0, 0, 0],
    'Gourmet': [0, 0, 0, 0],
    'Keitai': [0, 0, 0, 0],
    'Sports': [0, 0, 0, 0]
}

for k, v in texts_words.items():
    category = k.split('_')[1]
    bow = dictionary.doc2bow(v)
    topics = lda.get_document_topics(bow)
    
    top_topic = sorted(topics, key=lambda topic:topic[1], reverse=True)[0][0]
    topic_counts[category][top_topic] += 1

print('Topic counts:\n{}'.format(topic_counts))

結果は以下の通りとなりました。 例えば、グルメカテゴリの記事については、25本がトピック0、1本がトピック1、15本がトピック2、16本がトピック3に分類されていることを表しています。

京都観光カテゴリの記事がトピック0とトピック2(91本中73本で80%)、携帯電話カテゴリの記事がトピック1(79本中46本58%)に分類されており、概ねカテゴリ化できていることがわかります。

一方で、グルメカテゴリの記事もトピック0と2に寄ってしまっています。 グルメ記事の内容も、京都のお店が中心となっており、京都観光カテゴリの記事と重複する単語を多く含んでいたため、十分に分類できなかったと思われます。
スポーツカテゴリの記事については各トピックに分散してしまっています。 各単語は他のカテゴリと比較しても文書数が少なく、また、各記事で競技が多岐に渡っていたため(ボート、水泳、野球、剣道、他にも部活の思い出など)、カテゴリ内で重複する単語が少なく、その結果スポーツ以外の単語をベースにトピック分析された結果、分散してしまったと考えられます。

Topic counts:
{'Gourmet': [25, 1, 15, 16],
 'Keitai': [6, 46, 17, 10],
 'Kyoto': [40, 1, 33, 17],
 'Sports': [6, 6, 7, 3]}