BERTopicで文章群をイイ感じで分類してみる

## 背景
　最近、Mediumなどの記事でたびたび[ BERTopic ](https://medium.com/vennify-ai/topic-modelling-bertopic-8253a62bfad1)という文字が目に飛び込んできていていました。
ネットリサーチの業務でアンケートのフリーアンサー（自由記述）設問の回答を＂イイ感じで分類したい＂ケースはよくあります。そのためBERTを利用し[『集計作業のお悩みをAIで解決／第4話「文章自由回答データを効率的に集計する”教師なし学習AI”とは」](https://note.com/macromill/n/n99b58a4a8299?magazine_key=mf878af145bfc)』のように分類する機能を利用してきました。

NLPも他の機械学習と同様に「教師あり学習」と「教師無し学習」があり、「教師あり学習」は使いやすく結果の精度も安定した状態でツールとして運用できていて、もう数年もの間地道に利用されている状態です。しかし「教師あり学習」は教師データの学習というワンステップが必要なのと、この「教師データ」のクォリティにより使い物になるかどうかが決まってしまうという、幅広い一般のスタッフが実務としての運用するにはちょっとハードルが高いのも否めません。
これに対して「教師無し学習」は対象のデータがあればすぐに目的の処理を実行できます。
これは、これでよいのですが… しかしこの手法では機械での処理だけで80点以上の結果を出力できることはまずなく、事後作業として人の目で見て分類に手を加えるという工程が発生します。

そこでこのBERTopicを利用するとぉ、80点以上の結果を得られたり事後作業がう〜んと楽になる…　かもしれない。
これがサクッと実行できるのならば、試してみたいですよねぇ…

## BERTopicとは
　BERTopic という文字をたびたび見かけてはいるが、ネット上を彷徨ってみても日本語で言及されている記事などがあまりヒットしません。字面からして BERT＋Topic分析なのでしょうから、もっと興味を持たれても良いのかと思いますが… 
　しかし使い方が難しかったり日本語対応に手間がかかったりすると、英語がからっきしの私にはちとハードルが高いのかもしれません。ちょっと不安です。

 いろいろ探っていくと… 一般的に利用されているTopic分析の手法（解体素解析→TF-IDF→LDA）とは違い、BERTにより埋め込みモデルで表現し **c-TF-IDF** で特徴的なトピックを数値（ベクトル）化して、**hdbscan** により階層的クラスタリングで文章群を分類していることがわかります。

現状の文章分類のツールでは、各文章のBERT（Sentens-BERT）の埋め込みベクトル取得し、このベクトルでクラスタリングしています。
これでも、従来のTF-IDFよりは数段表現力は高まって（ベクトルが高密度に）いる結果にはなっているのですが、各トークンのベクトルでトピックとして扱えるのだとしたら非常に期待が持てます。

※ 実際にBERTopicが上記の創造のようなコードになっているかは不明です。私の（希望的な）想像です…
　さらに詳しく把握したい場合は以下の論文を読んで、間違っていたら指摘してください。 テヘっ
　　[『 BERTopic: Neural topic modeling with a class-based TF-IDF procedure 』](https://arxiv.org/abs/2203.05794)

<br><br>さて、能書きはこの辺で良いので、実際に使ってみましょう

---
## BERTopicを動かす
　　https://github.com/MaartenGr/BERTopic
上記の github を参照しつつ、インストールから進めていきましょう…

README.md に記載されているよう、Google Colabでの実行も可能なようなので、検証なのでこれで進めます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/c148d109-f039-7d07-e251-1cab889fe4b4.png)

pip でインストールします。
```python
!pip install bertopic

Looking in indexes: https://pypi.org/simple, https://us-python.pkg.dev/colab-wheels/public/simple/
Collecting bertopic
  Downloading bertopic-0.10.0-py2.py3-none-any.whl (58 kB)
     |████████████████████████████████| 58 kB 1.6 MB/s 
Requirement already satisfied: plotly>=4.7.0 in /usr/local/lib/python3.7/dist-packages (from bertopic) (5.5.0)
　：
　：
Installing collected packages: pyyaml, tokenizers, huggingface-hub, transformers, sentencepiece, pynndescent, umap-learn, sentence-transformers, hdbscan, bertopic
  Attempting uninstall: pyyaml
    Found existing installation: PyYAML 3.13
    Uninstalling PyYAML-3.13:
      Successfully uninstalled PyYAML-3.13
Successfully installed bertopic-0.10.0 hdbscan-0.8.28 huggingface-hub-0.8.1 pynndescent-0.5.7 pyyaml-5.4.1 sentence-transformers-2.2.2 sentencepiece-0.1.96 tokenizers-0.12.1 transformers-4.20.1 umap-learn-0.5.3
```
あら…　インストールはこれだけ。簡単

:::note info
　今回は Google Colab上で実行しますので、ここで Google Colab の＂ランタイム＂の再起動が必要なので、忘れずにランタイムの再起動を実行してください。
:::

では、実行〜！
fit_transform()に渡す docs は、文章群をlist型になっていればOKです。
docs = ['文章A', '文章B', '文章C',…]

**※ 11318レコードの日本語のデータを使用しています。**
 デフォルトのBERTモデルであれば、language のパラメータを指定することで日本語も扱えます。
（language を指定しなくても、おかしな結果にはならなかったのですが、まぁパラメータがあるので指定しておきます）
```python
from bertopic import BERTopic
topic_model = BERTopic(language="japanese", calculate_probabilities=True, verbose=True, nr_topics="20")
topics, probs = topic_model.fit_transform(docs)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/e86a9401-bb1c-3f19-dcdb-5a056e7595f4.png)

ちょっとWarningは出ていますが、動いちゃった…

## 結果確認
**トピック情報**
```python
topic_model.get_topic_info()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/a1390ece-2ecf-a672-dce6-8cc17de89930.png)


**intertopic distance map**
```python
topic_model.visualize_topics()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/bdb8c25b-8eb3-db87-dc29-1a621f0f05c9.png)

**Hierarchical Clustering**
```python
topic_model.visualize_hierarchy()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/cb0d8b89-ad68-2731-5d52-fbbf4faa4edc.png)


***すごい！ 簡単にアウトプットまでできちゃうんですねぇ…***

## ここまでのまとめ１
**イイところ**
　・簡単
　・インストールもスムーズ
　・処理時間もそれほど（数分）かからない
**ちょっと不満なところ**
　・クラスタNoが−1（外れ値）であるクラスが発生する
　・相当数（20〜30%）のレコードが−1（外れ値）のクラスに分類される
　・デフォルトでクラスタリングすると200弱の大量のクラス数に分類される

---
## 応用編１
**クラス数の指定**
　BERTopicの実行（インスタンス作成）コマンドのパラメータに nr_topics があり、これでクラス数（実行時は20を指定）をしていできるとなっている。しかし若干少なくはなっているような気がするが… 指定のクラス数が返るわけではないようです。
```python
topic_model = BERTopic(language="japanese"
                     , calculate_probabilities=True
                     , verbose=True
                     , nr_topics="20")　　　　　　　　　　# ← ここで指定
```

**外れ値を割り当て**
　BERTopicの実行（インスタンス作成）コマンドには外れ値を各クラスに再割り当てするオプションがあります。しかし−1（外れ値）が０レコードになるわけではないようなのです。

```python
topic_model = BERTopic(language="japanese"
                     , calculate_probabilities=True　　# ← ここで指定
                     , verbose=True
                     , nr_topics="20")
```
そして、実行後の結果の probs の値から、外れ値を各クラスに再割り当てする方法もあるようです。っが、こちらも同様に−1（外れ値）のレコードは０にはならず、かつtopicsの値だけに反映されます。なので intertopic distance map などで表示するにはさらに工夫が必要そうです。
```python
import numpy as np
probability_threshold = 0.01
new_topics = [np.argmax(prob) if max(prob) >= probability_threshold else -1 for prob in probs]
```

**クラス数の削減**
さらに… 次元を削減するコマンドも提供されています。最も頻度の低いトピックと最も類似したトピックを繰り返しマージすることで次元を削減している とのことです。
こちらはモデルに対して反映されていますので、intertopic distance map などで表示することも可能です。
っがしかし… −1（外れ値）のレコードは残ります。
というかむしろ、−1（外れ値）が増えてしまいました。約４割！？
```python
new_topics, new_probs = topic_model.reduce_topics(docs, topics, probs, nr_topics=20)
topic_model.get_topic_info()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/49be9c29-6b50-d4d1-6c67-25f2aafa37d1.png)

```python
topic_model.visualize_hierarchy()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/e645c4e8-0b9f-3b4b-7683-f3694424ece9.png)


## ここまでのまとめ２
**イイところ**
　・HDBSCANでの次元を削減できるメソッドも用意されている
　・次元を削減後も、次元削減前と同様に扱うことができる
**ちょっと不満なところ**
　・目的の次元に更新することができるが−1（外れ値）であるクラスが発生する
　・むしろ−1（外れ値）のクラスに分類されるレコードは増加する場合がある

運用面から見ると… −1（外れ値）が多くなるのは問題なのかもしれない…
この分類結果を見る人からすると、200に分類されてもその応用に苦慮することが想像されるし、たぶん20〜30くらいに分類されているべきである。っが… かといって20に分類して4割が−1（外れ値）に分類されるのも本意ではない。もっと良い分類方法はないものだろうか…？

---
## 応用編２
**クラスタリング手法の変更１**
凄いことに、このBERTopicではクラスタリング方法を変更することが可能です。

ここでは、K-Means でクラスタリングしてみました。K-Means ならば−1（外れ値）が発生せず強制的に指定のクラス数に分類してくれそうです。
ソフトクラスタリングであるGMMも試したのですが、エラーとなりこちらはサクッとは実現できなかったので、今回は断念します…
```python
from bertopic import BERTopic
from sklearn.cluster import KMeans
cluster_model = KMeans(n_clusters=20)   # ← ここでK-Means（20クラス）をクラスタリングのもでるとして指定します。

#topic_model = BERTopic(language="japanese", calculate_probabilities=True, verbose=True, nr_topics="20")  # ← このコマンドに hdbscan_model を追加します。
topic_model = BERTopic(language="japanese", hdbscan_model=cluster_model, calculate_probabilities=True, verbose=True)
topics, probs = topic_model.fit_transform(docs)
```python
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/f2c187dc-3ee9-17fd-c93d-4da19eb3b892.png)

あいかわらずWarningは出ていますが、動いちゃった…
クラスタリングもエラーなどは出ていないようですね。

```python
topic_model.get_topic_info()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/93defa09-4d8d-459f-665e-307b7a580488.png)

```python
topic_model.visualize_hierarchy()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/d5dedc07-9d82-671a-e289-56cdcff1055e.png)

K-Meansの特性上、当然−1（外れ値）のクラスは出力されていません。
逆にいうと、HDBSCANでは外れ値と判定された文章も、どれかのクラスに紛れ込んでいるのです…　20のいずれかのクラスに。

ちなみに、BERTopicを使わない現在の運用では、K-MeasnやGMMなどで複数のクラス数の出力をして、bicやinertiaの値を参考にしつつ、担当者の判断で少し多めのクラスタ数の結果を選択し、そこから人手でクラスの結合や分離・入れ替えなどで、クライアントとも会話をしつつ分類を確定していきます。
この部分の機械からの出力の精度を高めたいのですよねぇ…

**クラスタリング手法の変更２**
また、以下のように HDBSCAN のパラメータを変更することもできます。
ドキュメント内では、これにより−1（外れ値）を抑える方法として紹介されています。

HDBSCAN のパラメータである min_cluster_size と min_samples で調整（デフォルトでは min_cluster_size=min_samples）するのですが、min_samples が−1（外れ値）を抑える方法として効果があるようです。

```python
from hdbscan import HDBSCAN
cluster_model = HDBSCAN(min_cluster_size=3, metric='euclidean', 
                        cluster_selection_method='eom', prediction_data=True, min_samples=1)
topic_model = BERTopic(language="japanese", hdbscan_model=cluster_model, calculate_probabilities=True, verbose=True)
topics, probs = topic_model.fit_transform(docs)
```
しかし、これらの値を変更し−1（外れ値）を抑えるよう調整すると、クラスタ数が増えていく傾向にあります。例のように min_cluster_size=3, min_samples=1 すると、外れ値のレコードは 2,000程度までは削減されますが…　同時にクラス数が500以上に増加し、かつ Reduced dimensionality の処理時間が大幅に伸びてしまい、外れ値の削減効果の割にはデメリットの方が多い印象です。
私は、このチューニングを諦めました…

**BERTモデルの変更**
さらに凄いことに、HuggingFaceのBERT系のモデルに入れ替えることができます。

実は、現在運用しているツールのBERTモデルの中では [@sonoisa](https://github.com/sonoisa) さんの sentenceTransform ベースの Sentence-BERT の日本語モデルも（[こちら](https://qiita.com/sonoisa/items/1df94d0a98cd4f209051)を参考に）利用させていただいており、その後 HugginFace 内の [sonoisa/t5-base-japanese](https://huggingface.co/sonoisa/t5-base-japanese) を公開されていたのは知っていました。
これまでの自身の印象としては、一般的な BERT より Sentence-BERT の方が良い印象だったのです。

なので…　こちらのモデルを利用させてもらうこととします。
（合わせてクラスタリングも K-Means：20 で実行します）

```python
from bertopic import BERTopic

from sklearn.cluster import KMeans
cluster_model = KMeans(n_clusters=20)

from sentence_transformers import SentenceTransformer
sentence_model = SentenceTransformer("sonoisa/t5-base-japanese")　　# ← ここでsentence_transformersベースのモデルを指定します。

#topic_model = BERTopic(embedding_model=sentence_model, calculate_probabilities=True, verbose=True, nr_topics="20")
topic_model = BERTopic(embedding_model=sentence_model, hdbscan_model=cluster_model, calculate_probabilities=True, verbose=True)  # ← このコマンドに embedding_model を追加します。
topics, probs = topic_model.fit_transform(docs)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/11c08f9e-5ac7-24e7-d856-f86d106c3f85.png)

デフォルトのモデルより、1batchに時間がかかっているようです。
っが、エラーなく処理は完了です！

```python
topic_model.get_topic_info()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/f39a6bc9-b8c3-c0f9-1d63-4e58cb0a6c7d.png)

```python
topic_model.visualize_hierarchy()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2726928/c8fe0516-2cad-fd03-95de-773f70867c53.png)


## ここまでのまとめ３
**イイところ**
　・クラスタリング手法をK-Meansに変更できる
　・クラスタリングの手法を変えることで−1（外れ値）のクラスを無くすることができる
　・BERTのモデルを変更することができる
　・ Sentence-BERT のモデルを使用することもできる
　・クラスタリング手法をHDBCSANのパラメータを変更できる
**ちょっと不満なところ**
　・クラスタリングにGMMも利用できたらイイのに（頑張れば自分でできそうな気もする…）
　・K-Meansが利用できるならinertiaも取得できたら良いのに（頑張れば自分でできそうな気もする…）
　・HDBCSANのパラメータを変更しても、効果的に−1（外れ値）できない。

ちょっとできることを調べるのに時間がかかったり、できること・できないことを英語で把握するのに難儀もしましたが、パラメータの設定次第で簡単に素早くいろいろな手法や複数のモデルでの検証ができます。
いろんな手法やパラメータなりを簡単に試せたことにより、だんだん −1（外れ値）を数値的に判断してくれるのは、運用上のメリットになるのではないかと思い始めたりもしています…

---
# 全体のまとめ

　**・BERTopicでのトピック分析を試してみました**
　**・BERTopicの使い方を駆け足で説明しました**
　**・BERTopicに実装されている Visualizations 機能を試してみました**
　**・BERTopicに実装されている次元を削減する機能を試してみました**
　**・クラスタリングの手法を変更してみました**
　**・BERTモデルを変更してみました**

残念なこととしては、Visualizations での評価のみとなってしまったところです。
実際には、出力されたトピックやクラスをもっと詳しく読み込んで、 −1（外れ値）の価値や効果などを考察して、現状で運用している結果との比較をしてみたかったのですが、ちょっとここまでに時間をかけ過ぎてしまったので… 今回はここでタイムアップです。

感触としては、使いやすい！ことが印象的です。
また、これまでHDBSCANでのクラスタリングは敬遠していたのですが、＂外れ値＂もうまく使えば… という期待も芽生えてきました。BERTopicのドキュメントの中ではHDBSCANでの「＂外れ値＂を減らす」使い方（あまり減らない…）には言及があるので、その他のパラメータなんかをもっと調べて、さらに使いやすい方法がないか検証してみたいと思います。


実際これまで『文章の分類』に取り組んできた経験からは、MeCab＋TF-IDFの＂トピック分析＂では思ったような効果が得られず、BERTやGPT2などを応用した別の手法で分類してきたのですが、＂BERT＋トピック分析＂はもしかしたら… 『文章の分類』を少しだけ進化させられるかもしれない。という期待が持てました。


データは出力できていますので、時間をみて各結果の傾向やクセみたいなものを掴んで、さらにBERTopicを深掘りすべきなのか、確認してみたいと思います。


以上