Neural NetworkによるTopic Modelingとその実装

この記事では、Neural Topic Modelingについて調べたことをまとめます。

個人的解釈が多少含まれる記事となっていますので、気になる点がありましたら記事へのコメントやTwitterでリプライをいただければと思います。

Twitter : [@m3yrin](https://twitter.com/m3yrin)

## TL;DR
* 従来の確率生成モデルとしてのトピックモデルに対して、Neural Topic Modeling(NTM)の強みを説明します。
* PyTorchによってNTMの簡易な実装を行い、コードを公開します。
* 従来手法としてLDAでTopic Modelingを行い、NTMとの比較を行います。

## トピックモデルとは
トピックモデルは、文書集合で話題となっているトピックを、同じ文書で現れやすい語彙として抽出する手法です。
文書のメタ情報の抽出や、トピックを使って文書の分類に使用できます。
(岩田具治, トピックモデル 機械学習プロフェッショナルシリーズ)

トピックモデルでは、文書は構造化されている必要はないため、構造化させる手間がなく、使用しやすい分析手法と言えます。
(Amazon SageMaker ニューラルトピックモデルのご紹介, https://aws.amazon.com/jp/blogs/news/introduction-to-the-amazon-sagemaker-neural-topic-model/)


### Latent Dirichlet Allocation(LDA)
トピックモデルのモデル化の手法として有名なのは、潜在ディリクレ配分法(Latent Dirichlet Allocation; LDA)です。LDAでは一つの文書に複数のトピックを持つと仮定し、文章が生成される過程をモデル化します。
具体的には、下記のように表されます。

```math
\begin{aligned} \theta_{d} & \sim \operatorname{Dir}\left(\alpha_{0}\right), \quad \text { for } d \in D \\ z_{n} & \sim \operatorname{Multi}\left(\theta_{d}\right), \quad \text { for } n \in\left[1, N_{d}\right] \\ w_{n} & \sim \operatorname{Multi}\left(\beta_{z_{n}}\right), \quad \text { for } n \in\left[1, N_{d}\right] \end{aligned}
```


$\theta_d$ : 文書 $d$ に対するTopic分布
$z_n$ : 文書中の単語 $w_n$に対するトピック割当 
$\alpha_0, \beta_{z_{n}}$ : Dirichlet分布のhyper-parameter

LDAでは、文書集合が与えられたときの各パラメータの事後分布を変分推論やギブスサンプリングによって推論することが学習の目標となります。


<details>
<summary>memo(上式の解釈)</summary>
解釈をあえて書くと以下のような流れで確率分布から文章を生成します。
1. 文書dに対して、ディリクレ分布からトピックの分布$\theta_d$をサンプル
2. 文書d中の単語数$N_d$だけトピック$z_n$を割り当て
3. $z_n$に対応するパラメータ$\beta_{z_n}$で、単語$w_{n}$を生成
</details>


### 確率生成モデルの拡張の難しさ
LDAのようなシンプルなトピックモデルに対して、様々な拡張が考えられますが、モデルの表現力が強化されるほど、推論もより複雑になります。
相関トピックモデル等、Non-conjugate modelsではこれが顕著となるようです。[^2]

また、モデルの変更を行なった場合、それがたとえ小さな変更であったとしても、推論方法の再導出が必要となり、使用の障害になります。[^sutton]

### Neural Networkによるモデル構築
Neural Networkの高い表現力で、複雑な分布も近似できると予想できます。
事後分布を直接マップさせる推論モデルをNeural Networkで構築することができれば、確率生成モデルの困難さを回避できます。[^sutton]
[^sutton]: Srivastava & Sutton,  ICLR 2017, Autoencoding Variational Inference For Topic Models



## Neural Topic Model

Neural Topic Modelは、有り体に言えばVariational Autoencoder(VAE)であり、VAEをトピックモデルのコンテキストで使用します。

Wangらによる論文[^1]をもとに、最もシンプルなGaussian Softmaxモデル(GSM)を説明します。GSMは有限のトピック数を仮定するモデルです。
[^1]: ACL 2019, Topic-Aware Neural Keyphrase Generation for Social Media Language

### 

(NTMの評価を行ったMiaoらによる論文[^2]よりもWangらによる論文のモデルのほうが簡潔で著者実装も提供されているので、こちらを参考にします)
[^2]: ICML 2017, Discovering Discrete Latent Topics with Neural Variational Inference

![](https://i.imgur.com/n5STNgl.png)

### Bag of Wordsの生成

文書集合$C$

```math
C = \left\{ \mathbf { x } _ { 1 },\mathbf { x } _ { 2 }, ... , \mathbf { x } _ { | C | } \right\}
```
のそれぞれの文章$\mathbf{x}$から、BoWベクトル 


$$
\mathbf { x } _ { b o w } \in \mathbb{R}^{V}
$$

を作成します。$V$は語彙数です。

### Encoder

$$
\mu = f _ { \mu } \left( f _ { e } \left( \mathbf { x } _ { b o w } \right) \right),
\log \sigma = f _ { \sigma } \left( f _ { e } \left( \mathrm { x } _ { b o w } \right) \right)
$$
$f _ { * } ( \cdot )$ はReLUを活性化関数とする全結合層です。

### Decoder

以下のステップで$\mathbf { x } _ { b o w }$を再構成します。

- 潜在トピック変数zをサンプル$\mathbf{z} \sim \mathcal{N}\left(\mu, \sigma^{2}\right)$
- 混合トピックを計算 $\theta={softmax}\left(f_{\theta}(\mathbf{z})\right)$
- それぞれの単語$w \in \mathbf{x}$に対して
    -  $w \sim {softmax}\left(f_{\phi}(\theta)\right)$

特に$f_{\phi}$のWeight Matrixは、トピックに対する単語の分布$\left(\phi_{1}, \phi_{2}, \ldots, \phi_{K}\right)$とみなすことができます。これについては、後述します。

### 目的関数
目的関数は以下のように作ります。

```math
\mathcal{L}_{N T M}=D_{K L}(p(\mathbf{z}) \| q(\mathbf{z} | \mathbf{x}))-\mathbb{E}_{q(\mathbf{z} | \mathbf{x})}[p(\mathbf{x} | \mathbf{z})]
```

$p(z)$は標準正規分布、$q(z | x)$はデータに対する$z$の事後分布の近似であり、Encoder出力に対応します。
$p(x|z)$は、トピック変数から文章を生成するネットワークで、Decoderの出力に対応します。

目的関数も基本的にVAEと同じで、第一項はEncoderの出力と事前分布$p(z)$とのKLダイバージェンス損失、第二項は再構成損失になります。

### 実験と評価
Miaoらによる論文[^2]では、三つのデータセット(MXM song lyrics, 20NewsGroups, Reuters RCV1-v2 news)にて、NTMの性能を評価しています。

NTMのモデルとして、今回紹介したGSMと、Gaussian Stick Breaking(GSB), Recurrent Stick Breaking(RSB)、ベースラインとしてOnlineLDA, NVLDAというモデルでパープレキシティを評価し、NTMのモデルがベースラインを上回ったと報告しています。

![](https://i.imgur.com/cSCCB5j.png)



## 実装
Wang論文[^1]の著者実装( https://github.com/yuewang-cuhk/TAKG )にNTMの実装が含まれていたため、これを参考に簡易なNTMをPyTorchで実装してみました。

また、日本語のデータセットでNTMとGensimのLDAモデルで性能の比較を行ってみたいと思います。

### データセット
データセットとして、livedoorニュースコーパスを使用します。

https://www.rondhuit.com/download.html

前処理のコードについてはtdualdir氏によるブログ記事「LDAとそれでニュース記事レコメンドを作った。」を参考にしました。

http://tdual.hatenablog.com/entry/2018/04/09/133000

上の記事ではTokenizerとしてMecabを使用していますが、MecabをGoogle Colaboratoryで使用するのが手間だったため、TokenizerとしてJanomeを使用しています。

https://mocobeta.github.io/janome/

Janomeに合わせて、ドキュメントのtokenizerクラスを変更しています。

### NTMの実装

NTMの実装は下記に公開しています。

https://github.com/m3yrin/NTM


工夫した部分について、下記でコメントしたいと思います。

#### Tokenizer

データのTokenizerでは、URL・ストップワードの除去、特定の品詞の抽出などをしています。
JanomeにAnalyzerという前処理用のAPIがあるようだったので、それを使用してみました。

```python
class docTokenizer:
    def __init__(self, stopwords, parser=None, include_pos=None, exclude_posdetail=None, exclude_reg=None):
    
        self.stopwords = stopwords
        self.include_pos = include_pos if include_pos else  ["名詞", "動詞", "形容詞"]
        self.exclude_posdetail = exclude_posdetail if exclude_posdetail else ["接尾", "数"]
        self.exclude_reg = exclude_reg if exclude_reg else r"$^"  # no matching reg
        
        self.char_filters = [
                        UnicodeNormalizeCharFilter(), 
                        RegexReplaceCharFilter(r"https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+", u''), #url
                        RegexReplaceCharFilter(r"\"?([-a-zA-Z0-9.`?{}]+\.jp)\"?", u''), #*.jp
                        RegexReplaceCharFilter(self.exclude_reg, u'')
                       ]
        
        self.token_filters = [
                         NumericReplaceFilter(),
                         POSKeepFilter(self.include_pos),
                         POSStopFilter(self.exclude_posdetail), 
                         LowerCaseFilter()
                        ]
        
        self.analyzer = analyzer.Analyzer(self.char_filters, Tokenizer(), self.token_filters)
     
```

#### Dataloader

Dataloaderはインスタンス生成時にまとめてデータをBow形式に変換します。

```python
self.bow_data = np.array([bow_vocab.doc2bow(s) for s in data])
```

nextで呼ばれた際には、batch_sizeで指定されたサイズでバッチを返します。

gensimのデータ形式は下記のような単語のindexと出現頻度のタプルの形式では、Neural Netの入力としては使いにくいので

```
[(13, 1), (25, 1), (26, 1), (28, 2), (34, 4), (56, 13), (69, 1), (71, 3),  ...
```

self._pad(batch)でbowデータを(batch_size, bow_vocab)のsizeに変更します。bowデータで現れないindexは0で埋めるような処理をします。

```python
def _pad(self, batch):
        bow_vocab = len(self.bow_vocab)
        res_src_bow = np.zeros((len(batch), bow_vocab))
        
        for idx, bow in enumerate(batch):
            bow_k = [k for k, v in bow]
            bow_v = [v for k, v in bow]
            res_src_bow[idx, bow_k] = bow_v
            
        return res_src_bow
```

#### NTM.print_topic_words()
トピックを表す単語群はdecoderの$f_{\phi}$の重みから知ることができます。

もう少し具体的に書くと、トピック数が3の時のトピック変数$\theta$が
$$\theta = (1, 0, 0)$$

だったとすると、再構成される単語群dは

```math
d = {softmax}\left(f_{\phi}(\theta)\right) = {softmax}\left(W_{{\phi}}^{T}\theta\right) \\= {softmax}\left((\phi_{1}, \phi_{2}, \phi_{3})^{T}(1, 0, 0)\right) = {softmax}\left(\phi_{1}\right)
```

となります。$\theta$のそれぞれの要素番号に対応する$W_{{\phi}}$の要素を見ることで、トピックを表す単語を知ることができます。

実装では、$f_{\phi}$は

```python
self.fcd1 = nn.Linear(topic_num, self.input_dim)
```
なので、NTM.print_topic_words()では

```python
beta_exp = self.fcd1.weight.data.cpu().numpy().T
```
として、fcd1の重みを取得し、

```python
for k, beta_k in enumerate(beta_exp):
            topic_words = [vocab_dic[w_id] for w_id in np.argsort(beta_k)[:-n_top_words - 1:-1]]
```
のように、重みの大きい順に単語のindexを取得しています。




### 論文に書いていない実装
このセクションでは、論文には明記されていない内容を他の論文や実装を参考にして実装した内容です。個人的解釈のもとに実装していますが、間違っている場合には指摘していただけると嬉しいです。。

#### Perplexityの計算
Perplexityは下記で計算される指標で、トピックモデルや言語モデルの性能の指標とされます。Perplexityが小さいほど良い性能となります。
モデルがランダムな単語を返すモデルでは文書の語彙数、最小では1となります。

```math
perplexity\left(D_{\text { test }}\right)=\exp \left\{-\frac{\sum_{d=1}^{M} \log p\left(\mathbf{w}_{d}\right)}{\sum_{d=1}^{M} N_{d}}\right\}
```

指数部の分子は負の対数尤度なので、Cross Entropy lossを全文書の単語数で割って計算する形で通常は計算されるようです。

https://github.com/keras-team/keras/issues/2317

今回の実装でも、Cross Entropy lossを全単語数で割ることでPerplexityを計算しています。

```python
def compute_perplexity(model, dataloader):
    
    model.eval()
    loss = 0
    
    with torch.no_grad():
        for i, data_bow in enumerate(dataloader):
            data_bow = data_bow.to(device)
            data_bow_norm = F.normalize(data_bow)
            
            z, g, recon_batch, mu, logvar = model(data_bow_norm)
            
            #loss += loss_function(recon_batch, data_bow, mu, logvar).detach()
            loss += F.binary_cross_entropy(recon_batch, data_bow, size_average=False)
            
    loss = loss / dataloader.word_count
    perplexity = np.exp(loss.cpu().numpy())
    
    return perplexity
```

他の計算方法として、NVDM(Miao+, ICML 2016, Neural Variational Inference for Text Processing)の著者実装ではKL項を含むLossを全単語数で割る方法もあるようです。

https://github.com/ysmiao/nvdm/blob/19bc3d630bc0bace13555e2515de082771827a76/nvdm.py#L140

Perplexityの定義的にはKL項は含まれない方が良いと考えていますが、論文によってPerplexityを直接比較できない可能性があることは認識しておく必要があると思っています。

#### fϕのWeightのスパース率へのペナルティ
Wang論文[^1]の著者実装では、先に述べた目的関数に、$f_{\phi}$のWeightに対するL1ペナルティを加えた上でbackwordを行っています。

```python
def l1_penalty(para):
    return nn.L1Loss()(para, torch.zeros_like(para))

def update_l1(cur_l1, cur_sparsity, sparsity_target):
    diff = sparsity_target - cur_sparsity
    cur_l1.mul_(2.0 ** diff)
    
def check_sparsity(para, sparsity_threshold=1e-3):
    num_weights = para.shape[0] * para.shape[1]
    num_zero = (para.abs() < sparsity_threshold).sum().float()
    return num_zero / float(num_weights)
```

```python
loss = loss + model.l1_strength * l1_penalty(model.fcd1.weight)
```

L1ペナルティに対する係数`model.l1_strength`は、$f_{\phi}$のスパース率と目標のスパース率(`sparsity_target`)の差が大きいほど、大きな値に設定されます。(`update_l1()`)
`sparsity_target`は0.85など、比較的大きな値に設定され、$f_{\phi}$のWeightがスパースになるように働きます。

$f_{\phi}$のWeightをスパースにすることについて、論文ではその解釈を述べていません。

個人的解釈としては、複数のトピックに同じ単語が含まれないように$f_{\phi}$のWeightをある程度スパースにしているのだと考えています。妥当な実装だと思われますので、そのまま使用しました。

### 結果
はじめにNTMのloss等の変化について書きます。
パラメータは下記の通りです。

```python

# set random seeds
random.seed(123)
torch.manual_seed(123)

num_articles = -1

# data size limitation
max_src_len = 150
max_trg_len = 10
max_bow_vocab_size=100000

# Model parameter
hidden_dim = 1000 
topic_num = 20
target_sparsity=0.85

# Training parameter
batch_size = 32
learning_rate = 0.001
n_epoch = 300
```

![png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/329161/5b5208f9-2d75-0ca1-ac9a-f7b96251c562.png)

LossとPerplexityは~50 Epochでは単調に減少しますが、50 EpochからはL1ペナルティによる効果によりLoss、Perplexity共に増加します。Sparsityが`target_sparsity`で指定した0.85に到達し、100 Epoch程度で安定し始めます。

実験的にはSparsityが高くなるかわりに、Perplexity多少悪化するようです。

L1ペナルティの係数`model.l1_strength`の初期値は1e-7と小さい値に指定していますが、この値を大きすると、L1ペナルティの効果は50 epochより早く現れます。
`model.l1_strength`の初期値は小さくし、L1ペナルティの効果が現れるのをある程度遅らせた方が、最終的なPerplexityは小さくなるようです。


#### Perplexity
PerplexityではLDAの方が安定して良いスコアを出しました。

|||
|:-:|:-:|
| LDA @ 10 passes | 1654.3 |
| NTM @ 300 Epoch | 2994.5 |

#### トピック単語
NTMもLDAも概ねトピックごとにトピック単語をグループ化できています。
NTMの方が記号等(⇒, :@, ---, ◯)が含まれてしまっていますが、これは実装である程度改善できると思われます。
また、NTMに比べLDAでは単語の重複("映画"等)が多いように見受けられます。

##### NTM

```
Topic 0: ねこ store apps details タップ htc play :/ 要件 アプリ
Topic 1: ソフトウェア 更新 pin 書換え ⇒「 ケータイアップデート ローミング 当社 sms 手順
Topic 2: ビューアー ubuntu ultrabook windows インテル linux ssd usb dropbox mac
Topic 3: 占い nifty 鑑定 電力 歯 占い師 節電 消費 先生 測定
Topic 4: 転職 求人 年収 type :@ 辛口 説教 瞬時 お答え 入社
Topic 5: 撮影 シャッター 撮る 写真 カメラ 撮れる 露出 合成 作品 画像
Topic 6: 本田 ブータン ニキビ 中国 販売 市場 肌 購入 契約 価格
Topic 7: note galaxy sc siii サムスン ペン ロゴ samsung iii google
Topic 8: チョコレート ケーキ チーズ ショコラ 飴 クリスマス スイーツ チョコ 味わい 神社
Topic 9: 笑 テレコムスクエア ルータ レンタル --- 食べる レビュー 空港 medias 僕
Topic 10: 試合 野村 選手 sports 佑 なでしこ 戦 長友 野球 サッカー
Topic 11: 妄想 出産 バッテリー イケショップ 子ども 自転車 歯 ホラー ペット mah
Topic 12: xperia sx ダイエット gx 恋愛 体重 恋 レッツ acro セキュリティ
Topic 13: ケータイアップデート 河本 サッチャー ドバイ 鉄 受給 マーガレット ヘルプ 賞 グローブ
Topic 14: 小沢 クルマ smith 金子 スミス paul 栄子 自動車 吉田 ポール
Topic 15: ◯ msm gsm ghz lte mah medias qualcomm xi ワンセグ
Topic 16: 沢尻 神社 エリカ ライブ beetv line 曲 料理 歌う 会議
Topic 17: wimax キャプテン isw au アベンジャーズ kddi 犯罪 htc ヒーロー uq
Topic 18: 掲示板 批判 橋下 報じる 市長 有吉 韓国 相次ぐ 物議 ネット
Topic 19: ゴルフ ゴルファー パター スイング シャフト クラブ スコア レッスン ラウンド 練習
```

##### LDA

```
Topic 1: 選手 氏 試合 代表 戦 放送 番組 語る 監督 サッカー
Topic 2: 韓国 ネット 位 iphone 語 ケータイアップデート ユーザー 氏 心 掲示板
Topic 3: 自転車 ゴルフ 車 チョコレート クルマ ネット クラブ 自動車 被災 小沢
Topic 4: 賞 アプリ 映画 写真 東京 アカデミー 撮影 iphone 受賞 作品
Topic 5: アプリ android max ドコモ スマート 利用 エスマックス フォン サービス 向け
Topic 6: 写真 氏 テレビ ネット 売れ筋 作品 チェック iphone 展 ニュース
Topic 7: 企業 年収 会社 結果 位 やる % 調査 氏 香川
Topic 8: 応募 プレゼント キャンペーン クリスマス 当選 くださる 東京 いただく 期間 限定
Topic 9: 転職 仕事 求人 livedoor 会社 悩み 営業 東京 考える 部屋
Topic 10: 映画 作品 監督 公開 本 声 観る ネット 演じる 役
Topic 11: 画面 表示 ソフトバンク アプリ 設定 知る iphone facebook 入力 クリック
Topic 12: 肌 ケア 美 効果 美容 応募 当選 韓国 メイク 香り
Topic 13: ゴルフ % デザイン ブランド 女子 アイテム ファッション 商品 ポイント 男性
Topic 14: ネット 番組 放送 女子 テレビ !」 akb 声 好き やる
Topic 15: 更新 ソフトウェア くださる ダウンロード ビデオ 利用 アップデート データ 設定 表示
Topic 16: 結婚 男性 仕事 恋愛 独 相手 代 聞く 好き しれる
Topic 17: 映画 公開 ドラマ 演じる 本 映像 作品 dvd 役 監督
Topic 18: 対応 スマート フォン 機能 搭載 android モデル max サービス 端末
Topic 19: 製品 バッテリー 搭載 対応 pc 撮影 カメラ 充電 容量 usb
Topic 20: 映画 公開 孫 社長 作品 映像 本 シリーズ ジョン 韓国
```


#### 学習時間
計算コストはNTMの方が圧倒的に大きくなります。

* NTM
    * (100 Epoch) ~ 15分 w/ **GPU**
* LDA
    * (10 Passes) ~ 1分14秒 w/ CPU

## まとめ

* 従来の確率生成モデルとしてのトピックモデルに対して、Neural Topic Modeling(NTM)の強みを説明しました。
    * 確率生成モデルとしてのTopic Modelingでは、モデルの拡張や変更の際に推論が難しくなります。VAEとして構築することで、事後分布を直接推論でき、この困難さを解決できます。

* PyTorchによってNTMの簡易な実装を行い、コードを公開しました。
    * VAEをトピックモデルとして使用するときの工夫も説明しました。

* 従来手法としてLDAでTopic Modelingを行い、NTMとの比較を行いました。
    * 計算コストとしては圧倒的にLDAの方が軽く、性能も安定します。
    * Neuralに構築することで、細かなモデルの調整を行いやすくなります。
        * たとえば、トピック単語の重複が起こりにくくなるよう調整したい場合、推論モデルの全見直しは必要なく、LossにSparsityについてのL1ペナルティ項を加えるだけで実現できます。