gensimとjanomeを用いた日本語トピック分析

## この記事の目的
ずいぶん昔、このトピック分析を用いたサービスの開発を行なっていました。
最近は全く関係のないことばかりやっていたので、最新のライブラリの使い方を学び直す際のアウトプットをすることが一つの目的。
もう一つは実際にトピック分析をサービスに導入するという観点で記事を書くことです。

なのでこの記事は簡単にトピック分析の手順についての解説と、要所要所で実際の導入において留意せねばならない点を解説できればと思います。

##対象者
- テキストマイニング初心者
- トピック分析をサービスに導入することを検討する人

## トピック分析をはじめる

### 手順の概要
トピック分析を始める前にいくつかの事前準備が必要となります。

- 環境設定
- 文章準備
- 文章分割
- 辞書データ作成
- コーパス作成
- LDAトピックモデル作成
- LDAトピックを用いて文章のトピックを分析

基本的にはその他機械学習の手順と同じく、学習データを作成してモデルを構築し、そのモデルを用いて判別させるという流れになります。

今回はデータベース上に存在するテキストデータを学習データおよび判定対象としていきます。

### 環境設定
使用言語はpython3

#### gensimのインストール

```
pip install gensim
```

gensimはトピック分析を可能にするライブラリです。
トピック分析とは何か？　という方はまず[こちら](https://www.albert2005.co.jp/knowledge/machine_learning/topic_model/about_topic_model)を読んで見てください。

#### janomeのインストール

```
pip install janome
```

janomeは形態素解析を行うライブラリです。
このトピック分析においては単語は重要な解析対象となっており、日本語の性質のため学習データ処理に作業が追加で必要となっています。
形態素解析とは特に日本語において必要となる文章の解析で、文章を単語ごとに分割するものです。例えば
>すもももももももものうち

このような日本語文を単語ごとに分解するのが形態素解析です。単語ごとに分解すると次のようになります。
>すもも　も　もも　も　もも　の　うち

###文章分割（形態素解析）
辞書データおよびコーパスを作成するために、まずは文字列を単語で分割していきます。
今回使用するのは名詞のみなので、単語に分割した上で名詞のみを取得しましょう。

```
from janome.tokenizer import Tokenizer

def get_nouns(text):
    t = Tokenizer()
    terms = [token.surface for token in t.tokenize(text) if token.part_of_speech.startswith('名詞')]
    return terms
```

文章中に含まれる名詞リストを取得する関数を定義します。
`token.part_of_speech.startswith()`の引数に名詞を指定すれば名詞のみを取得できます。
品詞なども含めて取得したい場合はカンマ区切りの文字列を与えます。

####形態素解析をいつ行うかについて
形態素解析は時間のかかる処理です。単純に頻出単語を分析することもあるかと思いますので、文章の形態素解析はテキストデータの登録時に行なっておくと良いでしょう。

###辞書データ作成
辞書データを作成します。
辞書はテキスト中に出現する単語のリストとなります。今回は名詞のみで構成されることになります。

```
import mysql.connector
import gensim
from gensim import corpora

conn = mysql.connector.connect(
    host = 'XXXX',
    port = XXXX,
    user = 'XXXX',
    password = 'XXXX',
    database = 'XXXX',
    buffered = True
)
cursor = conn.cursor(dictionary=True)

get_query = "SELECT id, text FROM posts"
cursor.execute(get_query)
rows = cursor.fetchall()
```
最初はデータベースに接続し、テーブルに格納されているテキストデータを取得します。

```
dictionary = corpora.Dictionary([])

for row in rows:
    nouns = get_nouns(row['text'])
    #merge dictionary
    new_dictionary = corpora.Dictionary([nouns])
    dictionary.merge_with(new_dictionary)

dictionary.save(DICT_FILE_NAME)
```

データベースから取得したテキスト情報が`rows`に入っています。
最初に空のリストでディクショナリを作成し、ループの中でマージしていく方式です。
気をつけたいのは`corpora.Dictionary`の引数で、次のような単語リストのリスト構造でなければなりません。

```
[
 ['奇跡', '神秘', '真実', '夢'],
 ['魔力', '気力']
]
```

今回はテーブルの1レコードごとに処理をしていくので、`nouns`をリストにして渡します。
全ての文書のディクショナリを作成し結合したら、ファイルに保存しましょう。このディクショナリは最後に文章のトピックを分類する時にも使用します。
ディクショナリは最終的に単語ごとにIDが振られたデータ構造となります。

####単語の排除による精度向上
様々な機械学習に言えることですが、学習データをいかにチューニングするかが精度向上に繋がります。
このディクショナリ作成の場合、`dictionary.filter_extremes()`を用いて、出現頻度の高い/低い単語をディクショナリから排除することができます。
参照:https://radimrehurek.com/gensim/corpora/dictionary.html

例えばその文書集合で、その発信ユーザーの特性上頻出するキーワードがあるとします。これは排除すべきでしょう。
`例：輝夜月大好きなTwitterユーザーのツイートを分析する場合、竹は多く出現するので排除`
http://nlab.itmedia.co.jp/nl/articles/1801/30/news134.html

###コーパス作成
コーパスを作成します。
コーパスとは、各文書に、辞書に含まれる単語が何回ずつ出現しているかという文書集合の情報です。

同じループ内に記述を追加します。

```
corpus = []

for row in rows:
    #merge corpus
    corpus.extend([dictionary.doc2bow(nouns)])

corpora.MmCorpus.serialize(CORPUS_FILE_NAME, corpus)
```

コーパスも辞書と同じく最初に空のリストを作成し、こちらへextendしていきます。
コーパスは次のようなリスト構造となっています。

```
[[(0, 1), (1, 1)]]
```

単語ID=0の単語が文章に１回、単語ID=1の単語が１回という意味を持ったデータです。
コーパスはトピックの分類時には使用しませんが、後述のトピック粒度を変更する時には必要になりますので保存しておきましょう。

###LDAトピックモデル作成
モデルを作成するためのデータが揃ったところで、LDAトピックモデルを作成して行きましょう。
LDAとはLatent Dirichlet Allocationの略となります。

>LDAとは1つの文書が複数のトピックから成ることを仮定した言語モデルの一種です。
日本語だと「潜在的ディリクレ配分法」と呼ばれます。単語などを表層的と表現するならば、トピックは単語と違って表面には現れないので潜在的です。
引用元:https://abicky.net/2013/03/12/230747/

```
lda = gensim.models.ldamodel.LdaModel(
    corpus=corpus,
    num_topics=num_topics,
    id2word=dictionary
)
lda.save(LDA_MODEL_FILE_NAME)
```

`gensim.models.ldamodel.LdaModel()`でLDAトピックモデルを作成します。
引数としてディクショナリ、コーパス、そしてトピックの粒度を渡します。
トピックの粒度とは、その文書集合を幾つのトピックに分割かという数値です。こちらは指定せねばなりません。

最後にトピックモデルをファイルに保存してトピックモデルの完成です。

####トピックの粒度について
トピックの粒度を細かくすれば、より文章を細かく分類することができるものの、少しでも違う単語が含まれれば別のトピックとして扱われてしまいます。
大きくすればより広い意味でのトピックで分類できますが、大雑把な分類で意味をなさなくなる恐れがあります。

傾向としては広い話題について議論されるコミュニティーから発生するテキストデータであれば、粒度は細かく設定すべきでしょう。
専門性の高い話題について議論されるコミュニティーのテキストデータであれば、粒度は荒いほうが分類できます。

###トピック分析
では文章のトピックを分類して行きましょう。
今回はデータベースにある、とある投稿テーブルの各テキストデータを、作成したトピックモデルを用いて分類してそのIDを別のテーブルに記録して行きます。

```
def get_topic_number(corpus):
    lda = gensim.models.ldamodel.LdaModel.load(LDA_MODEL_FILE_NAME)
    
    topic_number = 0
    max_score = 0
    for topics_per_document in lda[corpus]:
        for topics in topics_per_document:
            pprint(topics)
            if max_score < topics[1]:
                topic_number = topics[0]
                max_score = topics[1]

    return topic_number

for row in rows:
    nouns = get_nouns(row['text'])
    texts = [nouns]
    corpus = [dictionary.doc2bow(nouns)]
    topic_number = get_topic_number(corpus)
    
    #insert
    insert_query = "INSERT INTO topics (post_id, topic_number) VALUES ('{}', {})".format(row['id'], topic_number)
    cursor.execute(insert_query);
```

流れとしては次のようになっています。
- 文章を形態素解析
- コーパスを作成
- トピック分類
- レコードを挿入

最初にトピック分類の関数を定義しています。
ここではまずトピックモデルのファイルを開いて、それを用いて分類をします。
トピック分析で得られるのは、トピックごとに文章にどれくらい関連があるかの数値データとなり、この数値が大きいほどそのトピックに文章が属している確率が高いことを意味します。
理解するためにコード中の`pprint`の出力結果を確認しましょう。

```
(0, 0.025002804)
(1, 0.025004514)
(2, 0.025004428)
(3, 0.02500223)
(4, 0.025003292)
(5, 0.02500517)
(6, 0.7749717)
(7, 0.025001207)
(8, 0.02500095)
(9, 0.025003701)
```

トピックモデルの粒度は10とした場合、次のリストが得られます。
このリストを見るに、文章は６番のトピック関連する可能性が最も高いので、ここでは最高スコアのトピックをその文章のトピックとして分類します。
上位nつのトピックを分類の判断材料としてもいいでしょう。

####トピックモデルの更新について
トピックモデルはある時点での文書集合におけるトピックをn個存在するとして作成されます。
よって分類時に文書集合に存在しない単語を含む文を与えても正しく分類できません。よって、トピックモデルは定期的にアップデートする必要があります。
なので何らかのバッチ処理をcronで実行する必要があるでしょう。

###トピックモデルは人に理解できるか？
トピックモデルはある文書集合においてトピックがnつあると仮定して、その単語出現頻度を元に作られます。
よって生成されたトピックモデルは`このトピックにはこれらの単語が含まれる`という情報の羅列です。簡単に表現すると下記となります。

```
初場所
土俵
まわし
```

上記の例は相撲だとわかりますが、実際はもっと複雑で理解し難いものになるので、そのままでは人間がわからないと思った方がいいです。つまりトピック３と判別されたものはなんの話題なのかは人間にはわかりません。

ただし、`ハッシュタグ`などの文章を構造化できるデータが存在する場合、人間がトピックモデルを理解する助けになります。例えばあるトピックにおいて頻出するハッシュタグを特定できれば、そのトピックがどのような話題なのかを理解することができます。

>例:トピック３に分類されるの文章の最頻出ハッシュタグは#Vtuberである。
すなわちトピック３に分類された文章は、Vtuberに関する話題について言及されている。

## トピック分析の活用
これまでの処理を行なった結果、投稿データごとにトピックを割り振ることができました。
例えばテキストがSNS等の各ユーザーに紐づいている場合、いくつか面白い施策が可能となります。

### ユーザーのマッチング
ユーザーごとにどのトピックについて多く発信しているかを特定できます。
同じトピックについて多く発信しているユーザーをマッチンクしてあげれば、コミュニティーの活性化に寄与できます。

### コミュニティ全体においての興味の分析
あるコミュニティに属するユーザーが、どのようなトピックに興味があるかを理解できれば、提供すべき機能だったり情報を判断することができます。

### 広告配信
例えばあなたが何らかのSNSを開発しており、そのサービスにおいて広告的なものを発信したい場合はこれが役に立ちます。
広告の文章をトピック分類して、ユーザーの最も興味のあるトピックと合致するコンテンツを配信すれば興味を持ってくれる確率が上がります。

コンテンツに興味を持ったユーザーがよく発信しているトピックを分析すれば、新たな顧客やプロモーション方法を思いつことも可能でしょう。

##最後に
ここまでトピック分析について書いてきましたが、トピック分析は構造化されていないテキストデータへの分析アプローチで、やり方次第では精度が出ません。
面白いからといってサービスに導入しようなんて考える前に、まずは手に入れられる構造化されているデータの分析から始めましょう。
ユーザーの性別、年齢、居住地、よく発信する時間。分析可能なデータはたくさんあります。

最後に残ってしまったテキストは、確かに美味しいので丁寧に味わいましょう。
テキストはデザートです。

## ソースコード
恥ずかしながら公開させていただきます。
https://github.com/acetaldehyde/topicmodel_example

## 参考資料
https://comaco.hatenablog.com/entry/2018/04/10/113808
https://qiita.com/icoxfog417/items/7c944cb29dd7cdf5e2b1
https://qiita.com/u6k/items/5170b8d8e3f41531f08a
https://en.wikipedia.org/wiki/Latent_Dirichlet_allocation
https://radimrehurek.com/gensim/index.html