BERTopicによるクラスタリングとクラスタのトピック抽出（livedoorニュースデータセットを対象）

# BERTopicとは
BERTopicでは、文書のクラスタリングとクラスタリングの解釈（クラスタのトピック抽出等）を行うためのライブラリです。

BERTopicの全貌を知るには、以下の公式ページの説明が、BERTopicの仕組みと使い方が簡潔にまとまっているため、非常にわかりやすいです。今回の記事はこちらを参考に執筆します。

https://maartengr.github.io/BERTopic/algorithm/algorithm.html

BERTopicは、次の5つ（+オプショナル1つ）のモジュールを組み合わせて、クラスタリングとクラスタリングの解釈のためのモデルを作ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378787/b6b38fb9-b8c2-4fac-e64b-f44e3edb6e84.png)
**公式よりBERTopicの概要図**

Embeddings -> Dimensionality Reduction -> Clustering -> Tokenizer -> Weighting scheme (-> Fine-tune Representations)というモデルでは、各モジュールがレゴブロックのように似たパーツと付け替え可能であるため、下図のように独自のモデルを組むことができます。モデルの中でも、特にクラスタリングとその前処理にあたる次元削減は、データセットに応じて処理を検討する必要があるため、このようにモジュール化されているのは、とても便利です。また、Embeddingsのモジュールは、TF-IDFの様なカウントベースのベクトルを使用できるという柔軟性を持ち、この点も魅力です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378787/0a878235-d2c9-b3fc-2729-1deb3c7a5d8f.png)
**公式よりBERTopicのカスタマイズ性を表現する図**

この記事では、BERTopicの使い方をコードと共にご紹介します。冒頭に述べた公式のWebページを参考に、livedoorニュースデータセットに対してクラスタリングとクラスタのトピック抽出を実施します。

# BERTopicの使い方を実例を踏まえてご紹介

## インストール
注意点だけ記載します。
・[fugashi](https://pypi.org/project/fugashi/1.0.2a9/)のインストールと併せて、unidicをダウンロードしてきています。
・[HDBSCAN](https://pypi.org/project/hdbscan/)のインストールに失敗する場合があります（エラーメッセージを見て、C++のビルド環境を導入したらインストールできました）。

## ライブラリインポート・環境変数インポート

```python
import numpy as np
import matplotlib.pyplot as plt

from fugashi import Tagger

from umap import UMAP
from hdbscan import HDBSCAN
from sklearn.feature_extraction.text import CountVectorizer

from bertopic import BERTopic
from bertopic.vectorizers import ClassTfidfTransformer
from bertopic.backend import BaseEmbedder

from langchain_openai import OpenAIEmbeddings
from langchain.embeddings import CacheBackedEmbeddings
from langchain.storage import LocalFileStore

from dotenv import load_dotenv
load_dotenv()
```
.envにOpenAIのAPI Keyを定義しておきます。


## モデル定義

```python
# Step 1 - Extract embeddings
store = LocalFileStore("./cache/")
embeddings_small = OpenAIEmbeddings(model="text-embedding-3-small")
cached_embedder_small = CacheBackedEmbeddings.from_bytes_store(
    embeddings_small, store, namespace=embeddings_small.model
)

class CustomEmbedder(BaseEmbedder):
    def __init__(self, embedding_model):
        super().__init__()
        self.embedding_model = embedding_model

    def embed(self, documents, verbose=False):
        embeddings = np.array(self.embedding_model.embed_documents(documents))
        
        return embeddings

## Create custom backend
custom_embedder = CustomEmbedder(embedding_model=cached_embedder_small)

# Step 2 - Reduce dimensionality
umap_model = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine')

# Step 3 - Cluster reduced embeddings
hdbscan_model = HDBSCAN(min_cluster_size=5, metric='euclidean', cluster_selection_method='eom', prediction_data=True)

# Step 4 - Tokenize topics

## 特定の品詞を抽出するための関数
def tokenize(text, target_pos):
    tagger = Tagger()
    tokens = [node.surface for node in tagger(text) if node.feature[0] in target_pos]
    return tokens

## テキストのトークン化
def preprocess(text):
    target_pos = ["名詞"]
    tokenized_texts = tokenize(text, target_pos)
    return tokenized_texts

vectorizer_model = CountVectorizer(analyzer=preprocess)

# Step 5 - Create topic representation
ctfidf_model = ClassTfidfTransformer()

# All steps together
topic_model = BERTopic(
    language="multilingal",
    embedding_model=custom_embedder,          # Step 1 - Extract embeddings
    umap_model=umap_model,                    # Step 2 - Reduce dimensionality
    hdbscan_model=hdbscan_model,              # Step 3 - Cluster reduced embeddings
    vectorizer_model=vectorizer_model,        # Step 4 - Tokenize topics
    ctfidf_model=ctfidf_model,                # Step 5 - Extract topic words
    verbose=True,
)
```
基本は、参照元のコードをベースにしています。変更のポイントは以下の通りです。

### Step 1 - Extract embeddings
langchainのCacheBackedEmbeddingsを使うために、Embedding作成用のカスタムバックエンドを定義しています。

### Step 3 - Cluster reduced embeddings
min_cluster_sizeを15から5に変更しています。BERTopicの機能として、クラスタ数を削減する機能も用意されているので、最初のクラスタでは小さなクラスタも許容することにしました。この辺は、使用するデータセットとの兼ね合いで、値を変える必要があると思います。

### Step 4 - Tokenize topics
fugashiをラッパーとして、unidic辞書を持ちいたMecabによる形態素解析を行い、名詞のみを取り出すようにしています。クラスタのトピックの表現には、名詞が相応しいと思い、名詞のみを抽出しました。動詞も「こと」を語尾につけることで名詞化できるので、動詞も含めて良いとも思っています。
ここで取り出した名詞のみを考慮して、TF-IDFベクトルが算出されます。

### Step 6 - (Optional) Fine-tune topic representations with a `bertopic.representation` model
クラスタのトピックの表現を「良く」する方法が多数用意されているみたいです。参照元では、オプショナルの扱いだったため、今回は割愛します。
詳細は、以下のページ等を参照ください。
https://maartengr.github.io/BERTopic/getting_started/representation/representation.html

## データセットの準備

データセットの準備は、[BERT Classification Tutorial](https://github.com/hppRC/bert-classification-tutorial/tree/main?tab=readme-ov-file)のprepare.pyで実行します。prepare.pyでは、下記のコードにより、テキストのクリーニングを前処理を行います。前処理後のデータは、訓練セット、検証セット、テストセットの３つに8:1:1で分割します。今回は、そのうちのテストセットのみを利用します。

```python
def process_title(title: str) -> str:
    title = unicodedata.normalize("NFKC", title)
    title = title.strip("　").strip()
    return title


# 記事本文の前処理
# 重複した改行の削除、文頭の全角スペースの削除、NFKC正規化を実施
def process_body(body: list[str]) -> str:
    body = [unicodedata.normalize("NFKC", line) for line in body]
    body = [line.strip("　").strip() for line in body]
    body = [line for line in body if line]
    body = "\n".join(body)
    return body
```

テストセットをメモリに読み込む関数は、下記のコードになります。

```python
import json

# JSONデータからテキストとカテゴリを抽出する関数
def extract_text_and_category(line):
    json_data = json.loads(line)
    return json_data['body'], json_data['category']

# JSONLファイルからテキストとカテゴリのリストを抽出する関数
def extract_data_from_jsonl(filepath):
    texts = []
    categories = []
    with open(filepath, 'r', encoding='utf-8') as file:
        for line in file:
            body, category = extract_text_and_category(line)
            texts.append(body)
            categories.append(category)
    return texts, categories

test_filepath = './datasets/livedoor/test.jsonl'
x_test, y_test = extract_data_from_jsonl(test_filepath)
```

## クラスタリングとクラスタのトピック抽出

### クラスタリングとクラスタのトピック抽出（初回）

```python
topics, probs = topic_model.fit_transform(x_test)
```
sckit-learnライクで実行できます。実行結果は以下の通り。
```bash
2024-05-06 16:56:39,656 - BERTopic - Embedding - Transforming documents to embeddings.
2024-05-06 16:56:41,033 - BERTopic - Embedding - Completed ✓
2024-05-06 16:56:41,035 - BERTopic - Dimensionality - Fitting the dimensionality reduction algorithm
2024-05-06 16:56:49,902 - BERTopic - Dimensionality - Completed ✓
2024-05-06 16:56:49,903 - BERTopic - Cluster - Start clustering the reduced embeddings
2024-05-06 16:56:49,935 - BERTopic - Cluster - Completed ✓
2024-05-06 16:56:49,940 - BERTopic - Representation - Extracting topics from clusters using representation models.
2024-05-06 16:57:04,802 - BERTopic - Representation - Completed ✓
```

どのようなトピックが含まれているか確認します。
```python
topic_model.get_topic_info()
```
出力の一部をトリミングして出します。出力は表形式で、各列の内容も列名から判断がつくのではと思います。
Topic列は、クラスタの番号で、Count列がクラスタの要素数で、Name列がクラスタの名前、Representation列がクラスタのトピックの表現にあたり、Representiative_Docs列がそのクラスタに含まれる文章（名前の通り”代表的な”文章なのかは未調査）です。
Topic列が-1である行データは、HDBSCANでクラスタに割り当てることができなかった外れ値をまとめたもののデータです（例えば、KMeansでは、この行は無くなると推察しています）。

| index | Topic | Count | Name                | Representation                                    | Representative_Docs                                                                                   |
|-------|-------|-------|---------------------|--------------------------------------------------|-------------------------------------------------------------------------------------------------------|
| 0     | -1    | 119   | -1_こと_人_Windows_1 | [こと, 人, Windows, 1, ゴルフ, 自分, 2, book, ゲーム, Face] | "とにかく安い挑戦価格!\n楽天は2日、同社子会社であるKoboが日本での電子書籍サービスを..." |
| 1     | 0     | 99    | 0_選手_日本_試合_代表   | [選手, 日本, 試合, 代表, 野球, 五輪, サッカー, チーム, こと, 監督]   | "7日、日産スタジアムで国際親善試合=チェコ戦を戦った日本代表は、1日のペルー戦前半と同じ3..."  |
| 2     | 1     | 98    | 1_映画_公開_月_監督    | [映画, 公開, 月, 監督, 作品, 本作, 世界, 賞, こと, 年]            | "東日本大震災後に公開延期を発表していた映画『のぼうの城』が11月2日に公開されることになっ..."    |
| 3     | 2     | 63    | 2_D_S_ドコモ_MAX     | [D, S, ドコモ, MAX, フォン, 4, 円, F, 0, 対応]                  | "ARROWS X F-10Dが7月20日発売予定!\nNTTドコモは12日、今夏に発売する..."                    |
| 4     | 3     | 41    | 3_結婚_こと_女性_独女   | [結婚, こと, 女性, 独女, 男性, 人, 自分, 相手, 代, メール]         | "たとえ片想いでも、気になる人がいる時はやはり楽しい。しかし、それを楽しみすぎて複数の男性が..."

### reduce topics(クラスタ数の削減）

表示していませんが、計27クラスタ（外れ値のクラスタ含む）あります。今回扱うクラスタ数をlivedoorニュースデータセットはカテゴリ数が分かっているので、その数にクラスタ数を削減します。カテゴリ数は9なのですが、外れ値クラスタのことを考慮して10にします。
コードは、下記リンクの**Topic Reduction after Training**を参考にしています。

https://maartengr.github.io/BERTopic/getting_started/topicreduction/topicreduction.html

```python
# Further reduce topics
topic_model.reduce_topics(x_test, nr_topics=10)

# Update the variable "topics"
topics = topic_model.topics_
```

```bash
2024-05-06 17:03:06,654 - BERTopic - Topic reduction - Reducing number of topics
2024-05-06 17:03:16,483 - BERTopic - Topic reduction - Reduced number of topics from 27 to 10
```

### reduce outliers(外れ値の別クラスタへの移動）

クラスタ数を減らした後は、外れ値を別のクラスタに移す処理を行います。また、移動先のクラスタで、クラスタの要素が変化するので、クラスタのトピック抽出を再実行します。
コードは、下記のリンクの**Topic Distributions**を参考にしています。

https://maartengr.github.io/BERTopic/getting_started/outlier_reduction/outlier_reduction.html

外れ値を別のクラスタに移す方法は、4つほど提案されています。詳細は、上記リンク先やライブラリのコードを確認してください。
> Using the topic-document probabilities to assign topics
**Using the topic-document distributions to assign topics(コードを見るとこちらがデフォルト）**
Using c-TF-IDF representations to assign topics
Using document and topic embeddings to assign topics

※**Topic Distributions**の説明の日本語訳
> .approximate_distributionで計算されたトピック分布を使用して、各外れ値文書で最も頻度の高いトピックを見つけます。distributions_params変数を使用して、.approximate_distributionのパラメータを微調整することができます。


```python
new_topics = topic_model.reduce_outliers(x_test, topics)
topic_model.update_topics(x_test, topics=new_topics, vectorizer_model=vectorizer_model)
```

## クラスタリング結果の確認

以上で、カテゴリ数のクラスタに文章をクラスタリングし、そのクラスタのトピックを抽出することができました。

### クラスタのトピックをキーワードで表現

クラスタの特徴を知るために、そのクラスタを特徴づけるキーワードをピックアップするのは良い手です。各クラスタのキーワードは以下のコードでアクセスできます。

```python
topic_model.get_topic(5)
```

```bash
[('女性', 0.05240423828160599),
 ('ダイエット', 0.04972677521477206),
 ('こと', 0.03383675386667723),
 ('体', 0.033022078816138556),
 ('夏', 0.028877032242178947),
 ('体操', 0.028478963169056744),
 ('気', 0.02831163195693844),
 ('ラジオ', 0.026868627204044912),
 ('運動', 0.026719391420445264),
 ('健康', 0.02639810520347047)]
```
このクラスタは、女性の美容・健康にかかわる文章が多く割り当てられているのかなと推察できます。
単語の横にある数字は、そのクラスタに割り当てられた文章内の単語に対して計算されたTF-IDFスコアです。この"get_topic"関数では、スコアの高いものをピックアップしています。TF-IDFスコアの計算は、モデル学習に合わせて実施されています。

### クラスタのトピックを図で表現

#### キーワード

一つ前の項で紹介した方法では単一のクラスタについての情報しか得られないので、複数のクラスタを比較した情報が欲しい所です。また、クラスタの情報を出力する際に、図で表現できると嬉しいです。
そのための機能として、"visualize_barchart"関数があります。

```python
topic_model.visualize_barchart(
    top_n_topics=9,
    n_words=5,
    width=250,
    height=250,
)
```

それぞれのトピックのキーワードが一望できます。

![newplot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378787/c02a8c33-da60-dcd2-4aae-62e36972022b.png)
**"visualize_barchart"関数のアウトプット。各クラスタのキーワードを棒グラフで表現できる。**

より詳細はこちら。

https://maartengr.github.io/BERTopic/getting_started/visualization/visualize_terms.html

#### 類似度

クラスタ間の類似度を定量的に計算し比較できます。公式を見ると、以下の文があり、TF-IDFと埋め込みの両方からトピックの埋め込みを作成したとあります。しかし、クラスタを表現するベクトルの具体的な作成方法の詳細は、コードを深掘りしないとわからないです。時間がある際に、調査します。
>Having generated topic embeddings, through both c-TF-IDF and embeddings, we can create a similarity matrix by simply applying cosine similarities through those topic embeddings. 

```python
topic_model.visualize_heatmap()
```
トピック（クラスタ）0~6の中には、似ているトピックもあり、うまくクラスタリングできていない可能性があります。特にトピック4とトピック6は似ています。年、月というキーワードがどちらにも出たため、似たのかもしれません。

![newplot_sscore.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378787/176faf45-fc6c-9216-7247-423e2278710f.png)
**"visualize_heatmap"関数のアウトプット。クラスタ間の類似度を測れる。**


より詳細はこちら。

https://maartengr.github.io/BERTopic/getting_started/visualization/visualize_topics.html#visualize-topic-similarity


#### 平面への埋め込み＋α

各クラスタでTF-IDFベクトルを求めた後、2次元に圧縮し、平面上に円としてプロットして、クラスタを表現します。クラスタ間の類似度※、及びクラスタのサイズ（円の大きさに対応）を簡単に理解できます。また、カーソルを円に合わせると、そのクラスタのキーワードを見ることができます。

※クラスタを代表するTF-IDFベクトルを算出する方法は、公式ドキュメントに記載がなかったので、ソースを確認する必要があります。時間がある際に、調査します。

```python
topic_model.visualize_topics()
```

円が重なっている部分は類似するクラスタだと考えられます。下図のTopic 3と被っている円は、Topic 5のクラスタであり、確かに"visualize_heatmap"でも、これらのクラスタは似ていると出ています。ちなみに、クラスタリング自体は、2次元より高い次元で行っているので、異なるクラスタが重なっていてもおかしくありません（クラスタリング使用したベクトルとクラスタの可視化に使用しているベクトルが異なるということ）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378787/70a0373b-9f14-63e0-bfd6-182791b8bef1.png)
**"visualize_topics"関数のアウトプット。クラスタ間の類似度、クラスタのサイズ、クラスタのキーワードの全てを含む。**

より詳細はこちら。

https://maartengr.github.io/BERTopic/getting_started/visualization/visualize_topics.html#visualize-topics


# 終わりに
BERTopicは、テキストのクラスタリングとクラスタのトピック抽出を簡単に実行できます。また、BERTopicは、カスタマイズ性が高いので、クラスタリングの試行錯誤が試しやすいのではと思います。実装の詳細やニッチな機能には手を出せませんでしたが、また、調査でき次第共有していきたいと思います。

# 参考
BERTopicとは何かについては、以下の記事にわかりやすい説明があります。
こちらもご参考ください。

https://www.nico-soda.jp/blog/post/20230426_000128.html