LDAで記事のクラスタリングをやってみる

# 概要

文書のクラスタリングに触れる機会があり、色々調べたので備忘録ついでにいくつか実装してみたい。ということで、そのうちの一つとしてLDAを使ってクラスタリング。対象データはニュース記事(livedoorのニュースコーパス)。

やることは3つ。
1. データの入手(livedoorニュースコーパス)
2. 道具の用意(gensim, mecabなど)
3. 実装(LDAでクラスタリング, 結果の可視化)

やらないこと
・LDAの理論の話
・その他難しい話

# 0. LDAとは
 - LDA(Latent Dirichlet Allocation)とは、トピックモデルと呼ばれる手法の一つで、自然言語処理においてクラスタリング手法の1つとして用いられる。
 - k-meansに代表されるような普通のクラスタリングとトピックモデルの違いが何かというと、1文章:1話題という前提ではないということ。トピックモデルでは1文章:N話題としている。つまりトピックモデルは、<b>ある文章は複数の話題(=トピック)から構成される</b>という思想に基づいた手法。
 - 例えば、「コーギーのお尻は食パンに似ている。」という文章について考える。トピックモデルは、"この文章のトピックはAnimal"というような出力しない。複数のトピックからなるという考え方に基づいているため、"この文章のトピックは、Anima:0.5, Food:0.5"というように出力する。

# 1. データの入手
livedoorニュースコーパスは以下から入手可能。
https://www.rondhuit.com/download.html

このデータセットは、映画・家電・IT・スポーツなど、合計9カテゴリのニュース記事がテキスト形式でまとめられたもの。(ざっと調べた感じでは他に有名なデータセットは見つからなかったが、こういったニュース記事系のデータって他にあるんだろうか。)
今回はこれを使ってクラスタリングしてみる。

# 2. 道具の用意
今回は、MeCabとgensimを使ってクラスタリングを行う。また、出来上がったLDAのモデルがどのようにトピックを割り当てているのか確認するため、WordCloudを利用する。

 - [MeCab](http://taku910.github.io/mecab/)

```
$ brew install mecab
$ brew install mecab-ipadic
$ pip install mecab-python3
```

 - gensim:

```
$ pip install gensim
```

 - [WordCloud](http://amueller.github.io/word_cloud/)

```
$ pip install wordcloud
```

# 3. 実装

### 1. データの読み込み・前処理
まず読み込むテキストデータのパスを取得する。

```python
import os
import pathlib

LIVEDOOR_NEWS_CORPUS_DIR = 'ここにダウンロードしたデータのディレクトリを指定'

data_dir_root = pathlib.Path(LIVEDOOR_NEWS_CORPUS_DIR)
data_dirs = [data_dir for data_dir in data_dir_root.iterdir() if data_dir.is_dir()]
article_paths = [path for data_dir in data_dirs for path in data_dir.iterdir() if os.path.splitext(str(path))[1] == '.txt']
```

試しに最初の記事を表示してみる。

```python
with article_paths[0].open() as f:
    print(f.read())
```
```:実行結果
http://news.livedoor.com/article/detail/5978741/
2011-10-30T10:15:00+0900
【DVDエンター！】誘拐犯に育てられた女が目にした真実は、孤独か幸福か
　2005年11月から翌2006年7月まで読売新聞にて連載された、直木賞作家・角田光代による初の長編サスペンス『八日目の蝉』。2010年に檀れいと北乃きいの出演によりテレビドラマ化された同作が、2011年4月に永作博美と井上真央の出演によって映画化。そして、劇場公開から半年が過ぎた10月28日、DVD＆ブルーレイとなって発売されました。

八日目の蝉
　妻子ある男と愛し合い、その子を身ごもりながら、あきらめざるをえなかった女。彼女は同時に、男の妻が子供を産んだことを知る。その赤ん坊を見に行った女は、突発的にその子を連れ去り、逃避行を続けた挙句、小豆島に落ち着き、母と娘として暮らしはじめる。

(省略)
```

最初の二行がURLとタイムスタンプになっていて、そのあとに本文が続いている形式。ちなみに記事のカテゴリを表示してみると以下の通り。

```python
categories = set([str(path).split('/')[::-1][1] for path in article_paths])
print('カテゴリ数:', len(categories))
print('カテゴリ詳細:', categories)
```
```:実行結果
カテゴリ数: 9
カテゴリ詳細: {'sports-watch', 'kaden-channel', 'peachy', 'smax', 'dokujo-tsushin', 'movie-enter', 'livedoor-homme', 'topic-news', 'it-life-hack'}
```

続いて、取得したパスからデータを読み込む。
読み込んだら分かち書きを行い、余計な文字や単語を取り除く。ここでは、MyTokenizer()を実装し、MeCabによる分かち書き・指定した品詞だけ抽出・余計な単語の除去ができるようにする。

今回は名詞のみを抽出し、LDAを適用する。(形容詞や動詞を含めてやってみたが、結果がなんとなく気に食わなかったので名詞だけにしてみた。)

また、以下は全て除外する。
 - ストップワード: 簡単に言うと、あらゆる文章に登場し、文章の意味を表さない単語。「それ」とか「あそこ」など。URLで指定して取得可能。
- URL: テキストデータの最初に記されている部分と、文章中の〇〇.jpなど
- 日付: テキストデータ2行目のタイムスタンプや、文章中の年月日など
- その他: 分かち書きの結果が1文字の場合、漢字のみ抽出している。記号1文字やひらがな1文字は除外。

```python
import MeCab
import re

class MyTokenizer:
    
    def __init__(self, stopwords, include_pos):
        self.include_pos = include_pos
        self.stopwords = stopwords
        mecab = MeCab.Tagger('-Ochasen')
        self.parser = mecab.parse

    def tokenize(self, text):
        text = re.sub(r'https?://[\w!?/\+\-_~=;\.,*&@#$%\(\)\'\[\]]+', '', text) # header: url
        text = re.sub(r'\d{4}-\d{1,2}-\w{5}:\d{2}:\d{2}\+\d+', '', text) # header: date
        text = re.sub(r'[\w!?/\+\-_~=;\.,*&@#$%\(\)\'\[\]]+.jp|[\w!?/\+\-_~=;\.,*&@#$%\(\)\'\[\]]+.com', '', text) # body: url
        text = re.sub(r'\d{4}年|\d{1,2}月|\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}', '', text) # body: date
        text = text.lower()
        results = []
        for result in self.parser(text).split('\n'):
            result_detail = result.split('\t') # "単語\t読み\t単語の原型\t品詞等\t..."で結果が渡される前提
            if len(result_detail) >= 4:
                word, pos = result_detail[2], result_detail[3].split('-')[0]
                if pos in self.include_pos and word not in self.stopwords: # 指定した品詞でかつストップワードでない
                    if len(word) > 1 or re.match(r'^[\u4E00-\u9FD0]', word): # 2文字以上か、1文字なら漢字だけ許容
                        results.append(word)
        return results
```
```python
res = urllib.request.urlopen("http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt")
stopwords = [line.decode("utf-8").strip() for line in res]
tokenizer = MyTokenizer(stopwords, ['名詞'])
```

MyTokenizer()を使ってデータを読み込む。(実装が悪いのか、データ数がそもそも多いのか、以下の処理に1分ほどかかった。)

```python
texts = []
for path in article_paths:
    with path.open() as f:
        texts.append(tokenizer.tokenize(f.read()))
```

最後に学習用とテスト用でデータを分割する。

```python
from gensim.corpora.dictionary import Dictionary
import random

# コーパス生成
dictionary = Dictionary(texts)
dictionary.filter_extremes(no_below=5, no_above=0.5)
dictionary.compactify()
corpus = [dictionary.doc2bow(text) for text in texts]

# 学習とテストに分割
random.shuffle(corpus)
train_size = int(len(corpus)*0.8)
corpus_train = corpus[:train_size]
corpus_test = corpus[train_size:]
```

### 2. モデル構築

データが揃ったところで、LDAのモデルを構築する。トピック数は、今回使うデータのカテゴリ数に合わせて9とする。手元の環境では、学習に20秒ちょっとかかった。

```python
from gensim.models import LdaModel

NUM_TOPICS = 9
lda = LdaModel(corpus=corpus_train, num_topics=NUM_TOPICS, id2word=dictionary, random_state=0)
```

### 3. 結果の確認

まず、モデルがどの程度良いのかを確認するため、Perplexityを見てみる。(※Perplexityに関しては[こちら](https://www.slideshare.net/hoxo_m/perplexity)が非常に参考になります。私も厳密な計算や詳細な意味は完全には理解できていません。)

```python
import numpy as np

num_words_test = sum([n for text in corpus_test for word_id, n in text])
perplexity = np.exp2(-lda.log_perplexity(corpus_test))
print('number of words:', num_words_test)
print('perplexity     : {:.1f}'.format(perplexity))
```
```python:実行結果
number of words: 330660
perplexity     : 404.6
```

出現単語数33万に対してPerplexityが約400。これがどれほど良いのか、あるいは悪いのか、モデルに問題があると考えられるのか、などは正直よく分かっていないが、Perplexityの直感的な理解からするとある程度良いモデルと言えそう。

さらに、WordCloudで各トピックに割り振られた単語を可視化してみる。

```python
import math
import matplotlib.pyplot as plt
from wordcloud import WordCloud

# 表示設定
N_TOP = 20
N_COL = 3
N_ROW = math.ceil(NUM_TOPICS / N_COL)

# トピックごとに画像生成
fig = plt.figure(figsize=(10, 10))
axs = [None]*NUM_TOPICS

for i, topic in enumerate(range(lda.num_topics)):
    
    def color_func(word, font_size, position, orientation, random_state, font_path):
        cm = plt.get_cmap('Dark2')
        base_color = [int(c*255) for c in cm(i%7)][:3]
        min_diff_from_white = min([255-c for c in base_color])
        random_diff = int(min_diff_from_white*random.random())
        return tuple([c+random_diff for c in base_color])

    fdict = dict(lda.show_topic(topicid=i, topn=N_TOP))
    image = WordCloud(background_color=(50, 50, 50),
                      font_path='/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc', 
                      color_func=color_func, 
                      width=400, height=300, 
                      random_state=0).generate_from_frequencies(fdict)
    axs[i] = fig.add_subplot(N_ROW, N_COL, i+1)
    axs[i].imshow(image)
    axs[i].axis('off')
    axs[i].set_title('Topic: {}'.format(topic+1))

# 表示
fig.tight_layout()
plt.show()

# 保存
plt.savefig('lda_result.png') 
```

結果はこんな感じ。

![lda_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526341/091581f7-4bef-4034-6cc5-926e5e610b80.png)

うん。何となくトピックを分けられているような気もする。ただ、ものすごく納得感があるかと言われるとそうでもない。今度はもう少し発展的な手法も実装してみたい。