BERTopicで日本語YouTubeコメントをトピック分類してみる

## はじめに

このブログはAidemy Premiumのカリキュラムの一環で、受講修了条件を満たすために公開しています。

## 目標

今、YouTubeのモバイルアプリには「コメントトピック」という、AI を活用して YouTube 動画のコメントを整理、要約する機能がついています。

https://support.google.com/youtube/answer/14239105?hl=ja

ただし現時点（2024年5月26日時点）では、YouTube モバイルアプリのコメント欄に大量のコメントがあり、動画とコメントの言語が英語の場合のみ使えるそうです。

そのため日本の動画では使えない機能なのですが、似たようなことを日本語のコメントでできないかと思い、試してみました。

## YouTubeモバイルアプリのトピック機能

下の画像をご覧ください。

![Topics_Lofi.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3775206/98e7284f-87a8-7c95-1f89-6a793ae28d9a.png)

こちらはLofi Girl の「1 A.M Study Session 📚 [lofi hip hop/chill beats]」という動画のモバイルアプリ画面です。

https://www.youtube.com/watch?v=lTRiuFIWV54&t=2620s

コメント欄に「トピック」というボタンがあります。

ここをタップすると、コメントがトピックで分けられてソートされます。

こんな感じのことをやってみたいと思います。

## どのように実装するか？

YouTubeのトピック機能は、コメントをトピックに分けるだけではなく、トピック名も自動生成しているようです。

今回はBERTopicの主要単語を元にしてトピック名を作ってみようと思います。

## BERTopicとは？

BERTopicとは、BERTをベースにした、文書のクラスタリングやトピック抽出等を行うためのライブラリです。

https://github.com/MaartenGr/BERTopic

デフォルトでは
 - ベクトル埋め込み：SBERT
 - 次元削減：UMAP
 - クラスタリング：HDBSCAN
 - トークナイザー：CountVectorizer
 - 単語の重み付け：c-TF-IDF
が基本の流れですが、カスタムしたりファインチューニングを加えることもできます。

また、トピックモデルからの可視化も簡単にできます。

## 環境

言語はPython。使用ツールはGoogle Colaboratoryです。

## YouTube Data API v3でコメントを取得

まずはBERTopicをインストールします。
```python
!pip install bertopic
```
以下のサイトを参考に、APIキーを発行。

https://engineering.webstudio168.jp/2022/09/youtube-data-api-getapikeys/

YouTube Data API v3は無料で使えます。※制限はかかります

こちらの記事を参考に、コメントを取得し、データセットを作成しました。

https://qiita.com/yaju/items/3bec88dbd544502e1343

今回の実験に使った動画はCreepy Nutsの「Bling-Bang-Bang-Born」です。

https://www.youtube.com/watch?v=mLW35YMzELE

```python
# YouTubeコメントからデータセットを作成
import pandas as pd
import requests
import json

URL = 'https://www.googleapis.com/youtube/v3/'

# ここにAPI KEYを入力
API_KEY = 'APIキー'

# ここにVideo IDを入力
VIDEO_ID = 'mLW35YMzELE'

# 空のDataFrameを作成
df = pd.DataFrame()
result_df = pd.DataFrame()

params = {
 'key': API_KEY,
 'part': 'snippet',
 'videoId': VIDEO_ID,
 'order': 'time',
 'textFormat': 'plaintext',
 'maxResults': 100,
 }

response = requests.get(URL + 'commentThreads', params=params)
resource = response.json()

for comment_info in resource['items']:
    # コメント
    text = comment_info['snippet']['topLevelComment']['snippet']['textDisplay']
    new_row = pd.DataFrame({'テキスト': [text]})
    df = pd.concat([df, new_row], ignore_index=True)
```

## テキストデータの前処理

今回のコードについては、こちらの記事を参考にさせていただきました。

https://qiita.com/hima2b4/items/e2fe3942b8e7253b8ed6

もとにしたコードはもっと前処理が入っているのですが、やりすぎるとコメントのソートという感じが薄くなってしまうので、今回は最小限にしています。

```python
def text_preprocessing(text):
   # 改行コード、タブ、スペース削除
   text = ''.join(text.split())
   # メンション除去
   text = re.sub(r'@([A-Za-z0-9_]+)', '', text)

   return text

df['テキスト'] = df['テキスト'].map(text_preprocessing)

# コメントに含まれている空行を削除
df['テキスト'] = df['テキスト'].replace('\n+', '\n', regex=True)
df.dropna(subset=['テキスト'], inplace=True)

# corpus_list（前処理し、データフレームに格納したテキストをリスト化）
corpus_list = df['テキスト'].to_list()
```

## BERTopicでトピック分け

BERTopicはトピック分けした後に、各トピック内の主要な単語を取得できるので、それを上から3つ繋げました。

```python
from bertopic import BERTopic

model = BERTopic(embedding_model="paraphrase-multilingual-MiniLM-L12-v2")  # 多言語モデルで日本語を使う

topics, probs = model.fit_transform(corpus_list)

topic_info_df = model.get_topic_info()

topic_representations = topic_info_df['Representation'].tolist()
topic_representatives = topic_info_df['Representative_Docs'].tolist()

for i in range(len(topic_representatives)):

    # トピック名は主要な単語の上から3番目までを_で繋げたものにする
    topic_name = topic_representations[i][0] + '_' + topic_representations[i][1] + '_' + topic_representations[i][2]
    new_row = pd.DataFrame({'トピック名': [topic_name], 'コメント': [topic_representatives[i]]})
    result_df = pd.concat([result_df, new_row], ignore_index=True)

result_df.head()
```

結果はこんな感じ。

|  | トピック名 | コメント |
| --- | --- | --- |
| 0 | 02_早口でぜんぜうたえない_28可愛いwww |[Teamo❤❤❤❤❤❤❤❤partedeErika😊😉, ❤❤❤❤😊😊😊❤❤You’reo... |
| 1 | it_2ヶ月まえか_チャンネル登録したやつ手上げろ | [これだけの再生回数で、広告無いのが凄すぎる！史上初？, この曲、大好きです🍀🍀ダンス💃があ... |
| 2 | tothetopoftheworld_itsbrawn_stotallyoutofthisw...	 | [やこよよなめのめみなわ（はらをよものものめのもよもよののたよやなはやなゆなかたやんわわんゃ... |

このようにトピック分けされました。

トピックが3つにしか分かれないのは少ない気もしますね。
おそらく荒らしのようなコメントは自動で削除されていると思うのですが、YouTubeのコメントはそんなに似通っているのでしょうか……？

## コメント取得方法の変更

もうちょっと精度を上げられないかと思って試していると、何故かコメントの取得数がおかしいことに気が付きました。
理由はよくわからないのですが、何度かやっていると取得するコメントが100件になったりすることがあって、不安定です。

そこで以下の記事のように、Google API Python クライアント ライブラリを使う方法に変更したら、安定しました。

https://qiita.com/Kaoru_Pythoness/items/f8996296a86ed47d8821

## ベクトル埋め込みモデルを変えてみる

SBERTのモデルを日本語用のsonoisa/sentence-bert-base-ja-mean-tokens-v2に変更しました。

まずはfugashiとIPAdicをインストールします。
```python
!pip install fugashi
!pip install ipadic
```

embedding_modelを変更。

```python
# SBERTのモデルをsonoisa/sentence-bert-base-ja-mean-tokens-v2に変更
model = BERTopic(embedding_model="sonoisa/sentence-bert-base-ja-mean-tokens-v2")
```

|  |トピック名  |コメント |
|---|---|---|
|0  |yeahcanyoutellmehowyoudidthat_forafriend_yeaht...  |[Hello, Hello, nowsingin'✊✊(ㅍ_ㅍ)Bling(ㅍ_ㅍ)✊✊Ba...  |
|1  |브링방방봉_한국인손_ကအ  |[บอกแล้วอย่าชีชีเดนด์, 한국인손, บอกแล้วอย่ามันส์ม...  |
|2  |アニメ_面白かった_本当にありがとうございます  |[マッシュの弾けるようになりましたピアノいいねを押しましたラップもすごいですね。私ダンスも少...  |
|3  |はーい_かこいい_さいこう  |[はーい, はーい🙋‍♀️🙋‍♀️！, ぬよよるめめめ（？ねにおもよも）ろの）もて）のよとも...  |
|4  |この歌好き_素晴らしい歌_いい歌です  |[この歌好き, この歌好き❤❤❤❤❤❤❤❤❤❤🎉🎉🎉🎉🎉🎉🎉🎉🎉, この歌好き❤🎉😊]  |

さっきよりトピック分類らしくなりました。

## トピック名をChatGPTでつけてみる

まずはOpenAIに課金してきました(無料期間が終わっていたため）。

続いてopenaiをインストール。

```python
!pip install openai
```

BERTopicで出てくる外れ値のコメントは「その他」というトピック名に設定し、最終的なコードはこうなりました。

```python
import openai
from bertopic.representation import OpenAI
from google.colab import userdata
import pandas as pd

# 空のDataFrameを作成
result_df = pd.DataFrame()

# YouTubeコメントからデータセットを作成
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

DEVELOPER_KEY = userdata.get("YT_API_KEY")
YOUTUBE_API_SERVICE_NAME = 'youtube'
YOUTUBE_API_VERSION = 'v3'

def get_video_comments(service, **kwargs):
    comments = []
    results = service.commentThreads().list(**kwargs).execute()

    while results:
        for item in results['items']:
            comment = item['snippet']['topLevelComment']['snippet']['textDisplay']
            comments.append(comment)

        if 'nextPageToken' in results:
            kwargs['pageToken'] = results['nextPageToken']
            results = service.commentThreads().list(**kwargs).execute()
        else:
            break

    return pd.DataFrame({'テキスト': comments})

    df = None

def main():
    global df

    # サービスのビルド
    youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, developerKey=DEVELOPER_KEY)

    # コメント取得
    video_id = 'mLW35YMzELE'
    df = get_video_comments(youtube, part='snippet', videoId=video_id, textFormat='plainText')

if __name__ == '__main__':
    main()

import re

# テキストデータの前処理
def text_preprocessing(text):
   # 改行コード、タブ、スペース削除
   text = ''.join(text.split())
   # メンション除去
   text = re.sub(r'@([A-Za-z0-9_]+)', '', text)

   return text

df['テキスト'] = df['テキスト'].map(text_preprocessing)

# コメントに含まれている空行を削除
df['テキスト'] = df['テキスト'].replace('\n+', '\n', regex=True)
df.dropna(subset=['テキスト'], inplace=True)

# corpus_list（前処理し、データフレームに格納したテキストをリスト化）
corpus_list = df['テキスト'].to_list()

# ChatGPTの設定
prompt = """
次のドキュメントを含むトピックがあります:
[DOCUMENTS]
このトピックは次のキーワードで説明されています:[KEYWORDS]

上記の情報に基づき、5語以内の短くて的確な日本語のトピックラベルを抽出してください。次の形式で返してください:
topic: <topic label>
"""

client = openai.OpenAI(api_key=userdata.get("OPENAI_KEY"))
openai_model = OpenAI(client, model="gpt-4o", exponential_backoff=True, chat=True, prompt=prompt)

representation_model = {
    "OpenAI": openai_model
}

# BERTopicでトピック分け
from bertopic import BERTopic

# SBERTのモデルをsonoisa/sentence-bert-base-ja-mean-tokens-v2に変更
model = BERTopic(embedding_model="sonoisa/sentence-bert-base-ja-mean-tokens-v2", representation_model=representation_model)

topics, probs = model.fit_transform(corpus_list)

chatgpt_topic_labels = {topic: " | ".join(list(zip(*values))[0]) for topic, values in model.topic_aspects_["OpenAI"].items()}
chatgpt_topic_labels[-1] = "その他"
model.set_topic_labels(chatgpt_topic_labels)

topic_info_df = model.get_topic_info()
topic_labels = topic_info_df['CustomName'].tolist()
topic_representatives = topic_info_df['Representative_Docs'].tolist()

for i in range(len(topic_representatives)):

    new_row = pd.DataFrame({'トピック名': topic_labels[i], 'コメント': [topic_representatives[i]]})
    result_df = pd.concat([result_df, new_row], ignore_index=True)

result_df.head()
```

結果はこうなりました。

|  |トピック名  |コメント |
|---|---|---|
|0  |その他  |[0:510:59, 0:59, 0:02チート、gifted、荒技、wanted0:03禁...  |
|1  |多言語混合話題  |[한국인손, 브링방방봉, บอกแล้วอย่ามันส์มาทางนี้]  |
|2  |アニメと曲の関係  |[神曲だと思う。なにかとのタイアップやコラボっぽい売り方をしなくても、一斉を風靡する曲だと思... |
|3  |このアニメ何？  |[whatanimeisthis?, Heyhey!WhyamItheonlyonetopo...  |
|4  |この歌大好き  |[この歌好き, この歌好き, この歌好き❤🎉😊] |

トピック名がつくと、YouTubeモバイルアプリのトピック機能っぽくなった気がしますね。

## おわりに
BERTopic面白かったです。ただコメントが20000件もあると、取得に時間がかかって動かすのに苦労しますね。大量のデータは適当なところでCSVとかにした方が効率的だったと思いました。