ニュース記事をLDAで使ってトピック解析をしてみる

# 概要

NLPの勉強を兼ねてlivedoorニュースコーパスをLDAを使ってトピック解析をしてみました。
※LDAをする際の手順をまとめたもので理論的な話は出てきません

livedoorニュースコーパスはlivedoorニュースで配信されたニュース記事を収集し、テキスト形式でまとめたものです。配信元のサイトから以下の9つのカテゴリに分けられています。

- トピックニュース
- Sports Watch
- ITライフハック
- 家電チャンネル
- MOVIE ENTER
- 独女通信
- エスマックス
- livedoor HOMME
- Peachy

これを使ってLDAでどんなトピックが得られるか、記事を9つのカテゴリに正しく分類できるかやってみます。

解析は以下の流れで行います。

1. データのダウンロード、読み込み
2. 前処理
   1. テキスト正規化
   2. URL除去
   3. 数値変換
3. コーパスの作成
   1. 単語分割
   2. ストップワードの除去
   3. 単語のIDマッピングと頻度による単語除去
   4. 文章のベクトル化
4. LDA
5. 結果


# 手順

## データのダウンロード、読み込み

[livedoorニュースコーパス](http://www.rondhuit.com/download.html#ldcc)をダウンロード、解凍しておきます。

今回はカテゴリも保存しておきたかったので、ディレクトリごとに読み込んでDataFrameに保存しています。

```python
import glob

import pandas as pd

directories = [
    "dokujo-tsushin",
    "it-life-hack",
    "kaden-channel",
    "livedoor-homme",
    "movie-enter",
    "peachy",
    "smax",
    "sports-watch",
    "topic-news",
]

articles = pd.DataFrame(columns=["dir", "filename", "url", "date", "title", "body"])
for directory in directories:
    files = glob.glob(f"text/{directory}/[!LICENSE]*")
    for file in sorted(files):
        with open(file) as f:
            lines = f.readlines()
            articles = articles.append(
                {
                    "dir": directory,
                    "filename": file.split("/")[-1],
                    "url": lines[0][:-1],
                    "date": lines[1][:-1],
                    "title": lines[2][:-1],
                    "body": "".join(lines[3:]),
                },
                ignore_index=True,
            )
```

## 前処理

トピック解析を行う上で障害となるテキストのノイズや表記の揺れをできるだけ事前に排除します。

### テキスト正規化

neologdnを使って表記の揺れを正規化します。

例）

- １-> 1（全角半角の統一）
- スーーパーー -> スーパー（連続する長音の削除）など

```python
import neologdn

bodies = [neologdn.normalize(body) for body in articles["body"]]
```

### URL除去

解析のノイズになりそうなURLを正規表現を使って除去します。

```python
import re

bodies = [re.sub(r"http\S+", "", body) for body in bodies]
```

### 数値置換

テキストに出現する数値を0で置換します。

具体的な数値があっても、語彙の数を増やすだけで、トピック解析には意味を持たないと考えられるためです。

```python
bodies = [re.sub(r"\d+", "0", body) for body in bodies]
```

## コーパスの作成

### 単語分割

形態素解析エンジンMeCabを使って単語分割をします。辞書にはmecab-ipadic-neologdを使っています。

トピック解析に役立ちそうな名詞、動詞、形容詞のみを抽出し、原型が辞書に登録されているものは原型を使います。

```python
import MeCab

docs = []
tagger = MeCab.Tagger("-d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd")

for body in bodies:
    words = []
    for parsed_line in tagger.parse(body).split("\n"):
        if parsed_line in ["EOS", ""]:
            continue
        fields = parsed_line.split("\t")
        fields.extend(fields.pop(1).split(","))

        if (
            (fields[1] == "名詞" and fields[2] in ["サ変接続", "ナイ形容詞語幹", "形容動詞語幹", "一般", "固有名詞"])
            or (fields[1] == "動詞" and fields[2] == "自立")
            or (fields[1] == "形容詞" and fields[2] == "自立")
        ):
            if fields[7] != "*":
                words.append(fields[7])  # 原型
            else:
                words.append(fields[0])  # 表層形

    docs.append(words)
```

### ストップワードの除去

事前に選定しておいた一般的な単語をデータから除去します。今回は[Slothlib](http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt)を使いました。

```python
with open("Japanese.txt") as f:
    stop_word = f.read().split()

print(sum([len(doc) for doc in docs])) # 1713234
docs = [[word for word in doc if word not in stop_word] for doc in docs]
print(sum([len(doc) for doc in docs])) # 1654142
```

今回のケースでは単語数で約6万語削減されました。


### 単語のIDマッピングと頻度による単語除去

文章をベクトル化するためにまずは単語とIDのマッピングを行う辞書を作ります。

gensimの`Dictionary`を使って行います。

`token2id`または`id2token`で各単語に振られたIDが確認できます。

```python
from gensim.corpora import Dictionary

dictionary = Dictionary(docs)
print(dictionary.token2id)
# {'0件': 0, '0歳': 1, 'あげる': 2, 'ありき': 3, 'あれこれ': 4, 'いい': 5, 'いう': 6, 'いえる': 7, 'いる': 8, 'お祝い': 9, 'お願い': 10, ....
```

また、今回は`filter_extreme`メソッドで一部の文章にしか出現しない単語や多くの文章に共通して出現する単語をはじいています。

- no_below: 出現文書数が`no_below`に満たない単語を削除
- no_above: 出現文書割合が`no_above`以上の単語を削除

```python
print(len(dictionary)) # 81415
dictionary.filter_extremes(no_below=5, no_above=0.5)
print(len(dictionary)) # 22275
```

dictonaryに登録された語彙が81,415語から22,275語に減っていることが確認できました。（減らしすぎ？）

### 文章のベクトル化

文章をLDAで解析できるようにベクトル化します。今回は単語の出現頻度で表すbag-of-words（BoW）で行いました。

```python
corpus = [dictionary.doc2bow(doc) for doc in docs]
print(corpus[0])
# [(0, 1), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1), (8, 1), (9, 3), (10, 4), (11, 1), (12, 2), (13, 3), (14, 5), (15, 1), (16, 2), (17, 1), (18, 1), (19, 1), (20, 1), (21, 1), (22, 1), (23, 1), (24, 1), (25, 2), (26, 1), (27, 4), (28, 4), (29, 10), (30, 1), (31, 3), (32, 3), (33, 1), (34, 1), (35, 1), (36, 1), (37, 1), (38, 1), (39, 1), (40, 1), (41, 1), (42, 1), (43, 1), (44, 2), (45, 1), (46, 2), (47, 3), (48, 1), (49, 1), (50, 1), (51, 1), (52, 2), (53, 5), (54, 1), (55, 1), (56, 2), (57, 1), (58, 1), (59, 2), (60, 2), (61, 1), (62, 1), (63, 1), (64, 2), (65, 1), (66, 1), (67, 2), (68, 1), (69, 1), (70, 2), (71, 1), (72, 1), (73, 2), (74, 2), (75, 1), (76, 2), (77, 1), (78, 1), (79, 1), (80, 1), (81, 1), (82, 1), (83, 1), (84, 2), (85, 1), (86, 2), (87, 1), (88, 2), (89, 1), (90, 1), (91, 2), (92, 4), (93, 1), (94, 1), (95, 2), (96, 1), (97, 1), (98, 2), (99, 1), (100, 3), (101, 2), (102, 1), (103, 1), (104, 1), (105, 2), (106, 1), (107, 2), (108, 2), (109, 2), (110, 1), (111, 1), (112, 1), (113, 1), (114, 1), (115, 1)]
```

BoWでは文章が上記のように(単語ID, 文章内の出現回数)のリストで表現されます。（出現回数が0回の単語は省略されます）

## LDA

LDAモデルを学習します。トピック数はデータのカテゴリ数と同じ9にしています。

```python
from gensim.models import LdaMulticore

_ = dictionary[0]  # id2tokenは遅延ロードされるため
id2word = dictionary.id2token
num_topics = 9

model = LdaMulticore(
    corpus=corpus,
    id2word=id2word,
    num_topics=num_topics,
    passes=10,
    random_state=123,
)
```

## 結果

まずはPerplexityを計算してみる。

Perplexityはモデルの予測性能を示すもので、1に近いほど性能が高いらしい。

```python
print(2 ** (-model.log_perplexity(corpus))) # 296.9222697094414
```

今回のモデルのperplexityは`296.92`でした。

次にLDAで解析した各トピックのコヒーレンス（u_mass）を表示します。

コヒーレンスはトピック品質を評価するもので、大きいほど品質が高いとされます。

```python
top_topics = model.top_topics(corpus)
avg_topic_coherence = sum([top_topic[1] for top_topic in top_topics]) / len(top_topics)

print("rank\tcoherence\tword")
for i, top_topic in enumerate(top_topics):
    print(f"{i+1}\t{top_topic[1]}\t{top_topic[0][:3]}")
print(f"avg\t{avg_topic_coherence}")

# out
# rank	coherence	word
# 1	-1.067699379315897	[(0.012865649, 'S'), (0.011643865, 'D'), (0.010598388, 'smartphone')]
# 2	-1.261029654781756	[(0.016427824, '更新'), (0.012466181, 'ソフトウェア'), (0.008563009, '機能')]
# 3	-1.2945931135133992	[(0.01509312, '思う'), (0.011386531, '0歳'), (0.010094971, 'ない')]
# 4	-1.3146827344990282	[(0.01797349, '映画'), (0.009497712, '公開'), (0.0073138834, '作品')]
# 5	-1.6056363696322933	[(0.016071873, '転職'), (0.010430117, '仕事'), (0.009396896, '年収')]
# 6	-1.618378856921537	[(0.010619343, '写真'), (0.008916671, '画面'), (0.0078825625, '撮影')]
# 7	-1.6452623587966837	[(0.01787416, '話題'), (0.008620801, '関連'), (0.00843563, '記事')]
# 8	-1.7070824216972351	[(0.008589254, '記事'), (0.00822273, '関連'), (0.0070402576, 'ネット掲示板')]
# 9	-1.7967113473620342	[(0.006227177, '女性'), (0.005489243, '思う'), (0.005262877, '多い')]
# avg	-1.4790084707244293
```

coherenceは平均で`-1.479`でした。

### WordCloud

各トピックの単語の出現頻度をWordCloudを使って可視化します。

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

fig = plt.figure(figsize=(12, 12))
for index, top_topic in enumerate(top_topics):
    ax = fig.add_subplot(3, 3, index + 1)
    wordcloud = WordCloud(
        font_path="NotoSansCJK-Regular",
        width=300,
        height=300,
        background_color="white",
        random_state=123,
    ).generate_from_frequencies(dict(model.show_topic(index, topn=20)))
    plt.imshow(wordcloud)
    ax.set_title(f"topic #{index}")
    ax.set_axis_off()
fig.tight_layout()
fig.show()
```

![index.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1285038/61466069-ed4b-1cf4-566f-eb0a54a3f8c6.png)

### 記事を分類する

トピック分布を計算しカテゴリごとにどのトピックが多いか確認します。

今回はトピックの確率が0.5以上で割り当てることとします。

```python
import seaborn as sns

result = [dict(model[c]) for c in corpus]
result = pd.DataFrame(result, columns=range(num_topics))
result = result >= 0.5

fig = plt.figure(figsize=(12, 12))
for index, directory in enumerate(directories):
    ax = fig.add_subplot(3, 3, index + 1)
    result_bool[articles["dir"] == directory].sum().plot.bar()
    ax.set_title(directory)
    ax.set_xlabel("Topic number")
fig.tight_layout()
fig.show()
```

![index.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1285038/5db86feb-dd77-a050-057a-57650e4d78be.png)

カテゴリごとの傾向が確認できますが、まずまずの精度で分類できているのではないでしょうか。

WordCloudと割り当てられた記事から考えると、

- Topic 1: IT
- Topic 2: 映画
- Topic 4: 家電
- Topic 5: スマートフォン
- Topic 7: アプリ

って感じでしょうか。

"livedoor-homme"は男性向けのサイトで内容も車とかファッションとか様々なのでトピックが散らばっていても仕方ない？。"sports-watch"は記事の内容的にスポーツっぽいトピックが出来ても良さそうな気がするが、トピック6は"dokujo-tsusin"にも多く、スポーツっぽい単語が上位には出てきてない。

# 比較

LDAをやってみて気になった点をいくつか比較してみます。

## 前処理の効果

今回はニュース記事に3つの前処理をしてましたが、前処理なしの結果も出してみました。

|  | perplexity | coherence|
| ---- | ---- | ---- |
| 前処理あり | 296.92 | -1.479 |
| 前処理なし | 327.46 | -1.699 |

![index.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1285038/ce7673ca-0f89-d575-aa83-5b55c203a69a.png)

![index.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1285038/5c4dd681-af76-c7e0-5958-ecb8f9b9070f.png)

前処理をなくすとperplexity、coherenceともに値が悪化していて効果はありそう。トピックを見てみると前処理ありではある程度分けられていた"it-life-hack"と"kaden-channel"がトピック0にまとめられていて、IT+アプリっぽいトピックが出来ている。似たような映画トピックが二つに増えてしまっているのも気になる。前処理ありではなかったスポーツっぽい単語が上位のトピックが出来ているのは良さそう。

## 単語除去の効果

ストップワードや`filter_extreme`を使った単語除去をしていない結果も出しました。

|  | perplexity | coherence|
| ---- | ---- | ---- |
| 単語除去あり | 296.92 | -1.479 |
| 単語除去なし | 356.75 | -1.791 |

![index.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1285038/1b1685ce-2ab3-fab9-b834-3ff9b7f9b2ac.png)

![index.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1285038/719630b5-7e57-e086-b7b5-a9f560854d04.png)


こちらもperplexity、coherenceともに値が悪化していることが確認でき、今回のデータでは前処理よりも効果が大きい。「する」、「なる」、「できる」などが各トピックの上位に出ていて、このあたりの単語は明らかに削除したほうが良さそう。評価値は悪化しているがトピック的には一番うまく分類出来ていそうな気がする。

# 最後に

処理をいろいろ加えると評価値は良くなっていたが、どのモデルが良いとを判断するのか難しい。livedoorニュースコーパスはもとからデータが整形されている綺麗なテキストなので前処理を追加したところで結果に大きな影響はなかったかもしれない。単語除去はトピックの単語の出現頻度に大きく影響が出ていて、これはきちんと処理をした方が結果を解釈しやすくなりそうです。

トピックモデル難しいなぁ。。