歌みた動画のタイトルと概要欄から楽曲情報を取得する (ChatGPT編)

# まとめ
* gpt-3.5-turboを使うとZero-shotで楽曲情報を抽出する問題がある程度解ける
    * 完全正解率 54.26% (70/129)
    * 楽曲名のみ 89.15% (115/129)
* プログラム経験あるなら、ChatGPTへの入力として疑似コード書くのは使えそう

# やりたいこと
歌ってみた動画のタイトルと概要欄から以下を抜き出したい
* 楽曲名
* 歌い手名
* カバーかどうか
* （カバーの場合、）元アーティスト名 or ボカロP名
* （カバーの場合、）原曲のURL

### 例1 カバー曲の場合
https://www.youtube.com/watch?v=xWLj4ULWHlg

入力:
* タイトル: 【3D LIVE】Habit/SEKAI NO OWARI covered by ジョー・力一＆レオス・ヴィンセント【にじさんじ】
* 概要欄: ジョー・力一3D LIVEより抜粋\nゲスト レオス・ヴィンセント @LeosVincent\n...(以下略)

出力
```json
{
    "song_title": "Habit",
    "singers": ["ジョー・力一", "レオス・ヴィンセント"],
    "is_cover": true,
    "artists": ["SEKAI NO OWARI"],
    "original_url": "https://www.youtube.com/watch?v=8OZDgBmehbA"
}
```

### 例2 オリジナル曲の場合
https://www.youtube.com/watch?v=Q8NjemtU-NI

出力
```json
{
    "song_title": "Alche Miss Katrina",
    "singers": ["アンジュ・カトリーナ"],
    "is_cover": false,
}
```

# 今までの流れ

（以下全て、楽曲名のみの取得）
1. 正規表現で頑張る編: https://qiita.com/miyatsuki/items/cb0ce6ae0e48cf42b1c8
2. OpenAI(GPT-3)編: https://qiita.com/miyatsuki/items/6429340dae118392f117
3. huggingface AutoTrain編: https://qiita.com/miyatsuki/items/0f8080edec703c604929

# やったこと
* ChatGPT(gpt-3.5-turbo)に楽曲情報取得タスクを解かせる
* 以下を参考にPythonコードっぽいものをプロンプトとして渡すようにする

https://twitter.com/8kpx/status/1637284615126695937

## 実装コード

今回は慣れているので、gpt-3.5-turborに渡す言語としてpythonを利用した
ただし、最終形がJSONの場合はtypescriptを使った方が良さそう
pythonの場合はnullをNoneと表現するが、このせいで最後の出力がNoneになってパース失敗する事例があったため
もしくはpythonを使うならjson.loadsでパースするよりevalでパースしちゃった方が成功率は高いかも

```python
import json
import os
import time
from typing import NamedTuple, List

import openai
from openai.error import APIConnectionError, Timeout
import tqdm


class Video(NamedTuple):
    video_id: str
    video_title: str
    description: str

# 今回は省略しますが、なんらかの方法でVideoのリストを作ってvideosに入れてください
videos: List[Videos] = []

# なんらかの方法でopenaiのAPIが呼べる状態にしておいてください
openai.api_key = os.environ["OPENAI_API_KEY"]

def python_simulator(video: Video) -> bool:
# context一行目と最後の行のバックスラッシュはQiita用に追加したものです

# 以下のpromptの冒頭での存在しない関数をimportは「これエミュレートしたらNameErrorになるよ」という返しを回避するためです
    context = f"""
\```python
import json
from typing import List, Union, Dict, Optional
import extract_song_title, extract_song_title, extract_singers, extract_original_artists, is_cover, extract_original_url, convert_to_parsable_json

video_title = "{video.video_title}"
description = "{video.description[:2000]}"

answer: Dict[str, Union[str, List[str]]] = {{}}

# 動画のタイトルと概要欄から、曲名だけを抽出する。取得できない場合はNoneを返す
song_title: str = extract_song_title(video_title=video_title, description=description)
if song_title:
    answer["song_title"] = song_title

# 動画のタイトルと概要欄から、歌い手名を抽出する。取得できない場合は[]を返す
singers: List[str] = extract_singers(video_title=video_title, description=description)
if singers:
    answer["singers"] = singers

# 動画のタイトルと概要欄から、カバー曲かどうかを判定してフラグ追加
answer["is_cover"] = is_cover(video_title=video_title, description=description):

if answer["is_cover"]:
    # 動画のタイトルと概要欄から、オリジナルのアーティスト名を抽出する。取得できない場合は[]を返す
    artists: List[str] = extract_original_artists(video_title=video_title, description=description)
    if artists:
        answer["artists"] = artists

    # 動画の概要欄に、カバー元のURLが含まれていたらそれを返し、なければNoneを返す
    original_url: Optional[str] = extract_original_url(description=description)
    if original_url:
        answer["original_url"] = original_url

answer_json_string = json.dumps(answer, indent=4, ensure_ascii=False))

print(answer_json_string)
\```
上記のコードの実行をシミュレートしてコンソール出力を予想してください。
実装がない箇所は関数名から挙動を仮定しながら進めてください。
"""

    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": "You are a python simulator, which simulates the evaluation result of input"},
            {"role": "user", "content": context[1:-1]}
        ],
        max_tokens=1024,
        temperature=0,
    )
    
    return response.choices[0]['message']['content']


def correct_json(text: str):
    prompt = f"""
    以下のJSONをパース可能に修正してください。修正点は記載せず、JSONだけを返してください。
    ```json
    {text}
    ```
    """

    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": "You are a helpful assistant, which returns answer in JSON format."},
            {"role": "user", "content": prompt[1:-1]}
        ],
        max_tokens=1024,
        temperature=0,
    )

    return(response["choices"][0]["message"]["content"])


def parse_sim_json(output: str, retry_ok: bool=True):
    lines = output.split("\n")

    back_quotes_index = [
        index for index, line in enumerate(lines) if line.startswith("```")
    ]
    start_index = back_quotes_index[-2] + 1
    end_index = back_quotes_index[-1]

    try:
        return json.loads("\n".join(lines[start_index:end_index]))
    except JSONDecodeError:
        if retry_ok:
            corrected_json = correct_json("\n".join(lines[start_index:end_index]))
            return parse_sim_json(corrected_json, retry_ok=False)
        else:
            return eval("\n".join(lines[start_index:end_index]))

for video in tqdm(videos):
    try:
        r = parse_sim_json(python_simulator(video))
        print(r)
    except APIConnectionError as e:
        print(e)
        time.sleep(10)
        continue
    except Timeout as e:
        print(e)
        time.sleep(10)
        continue

    # RateLimitに引っかからないように適当な秒数待つ
    time.sleep(6)

```

# 精度
* 完全正解 54.26% (70/129)
* 楽曲名は正解 89.15% (115/129)
* 歌い手名は正解 78.29% (101/129)
* カバーかどうかの判定は正解 92.25% (119/129)
* アーティスト名は正解 71.70% (76/106)
* 本家URLは正解 83.96% (89/106)

※ 評価コード実装簡略化のため、[]はnullは要素がないものとして判定

なお、1300件弱のデータでAPI実行したが、上記コードで全てパース可能だった
JSONパースできなかった時のための予防措置を置いてはいるが、基本的には普通にjson.loads()で読み込める

### 推定に成功した例
https://www.youtube.com/watch?v=90KG00GQLWA

出力
```json
{
    "song_title": "月光",
    "singers": [
        "セラフ・ダズルガーデン",
        "四季凪アキラ"
    ],
    "is_cover": true,
    "artists": [
        "はるまきごはん",
        "キタニタツヤ"
    ],
    "original_url": "https://www.youtube.com/watch?v=SaX0By61S3U"
}
```


### 推定に失敗した例
https://www.youtube.com/watch?v=El3l-a9lnmg

出力
```json
{
    "song_title": "ロウワー",
    "singers": [
        "リゼ・ヘルエスタ",
        "椎名唯華",
        "葉加瀬冬雪",
        "アルス・アルマル",
        "健屋花那"
    ],
    "is_cover": true,
    "artists": [
        "Flower"
    ],
}
```
Flowerは(広義の)ボカロなので、推定失敗
概要欄にはボカロP名が書かれていないので、"artists"要素がないのが正解
なお、それ以外は完璧に当てている

### 推定に失敗した例2
https://www.youtube.com/watch?v=JYtt0inzDR4

```json
{
  "song_title" "BLOODY STREAM",
  "singers": ["宝鐘マリン"],
  "is_cover": True,
  "artists": ["Coda"],
  "original_url": "https://www.youtube.com/watch?v=QzBmQMyYDBk"
}
```

* original_urlが関係ないYouTube動画になっており、ChatGPTのナチュラル嘘つきが発動している
* アーティスト名のCodaは概要欄に記載がないので、空欄になるのが期待した回答
    * ...が、確かにこの曲のアーティストはCodaなので、事実としては正しい。ChatGPT自体の学習データに含まれていたものと思われる
* 全体的に、情報が足りてない時に変な嘘をついたり、無理やり要素を埋めたりして精度を下げている印象

# 所感
* 精度を抜きにしても、「JSON出力して」でほぼ破綻なくJSONが出力され続けるのが驚き
* 精度面で言うと、楽曲タイトル抽出用にfine-tuningしたモデルには数%劣る
    * とはいえ、zero-shotでこんなに精度出るんだと言うのが率直な感想
    * <a href="https://qiita.com/miyatsuki/items/6429340dae118392f117">GPT3で試した時はfew-shotで精度70%程度だった</a>ので隔世の感がある。まだ2年経ってないけど
* 精度に課題はあるが、APIコール1回で必要な情報が一気に取得できるのはお手軽で良い。
    * RateLimitに引っかかりまくって困っているので、そこだけなんとかなるともっと良い。。。
    * ここから精度を上げようとすると事前情報が必要になってきそう。fine-tuningだったりGPT-4からのtoken数上限アップなどを使っていくことになりそう
* 今回やったことを自然言語でちゃんと伝えようとすると結構大変なので、既存の形式言語が使えるの、個人的にはかなり楽


# おまけ

最初はこういうプロンプトを使っていました

```python
def python_simulator(video: Video) -> bool:
    context = f"""
\```python
import json
from typing import List, Optional
import extract_song_title, extract_song_title, extract_singers, extract_original_artists, is_cover, extract_original_url_in_YouTube

song_title_key = "song_title"
singers_key = "singers"
artists_key = "artists"
original_url_key = "original_url"

video_title = "{video.video_title}"
description = "{video.description[:2000]}"

# 動画のタイトルと概要欄から、曲名だけを抽出する。取得できない場合はNoneを返す
song_title: str = extract_song_title(video_title=video_title, description=description)

# 動画のタイトルと概要欄から、歌い手名を抽出する。取得できない場合は[]を返す
song_title: List[str] = extract_singers(video_title=video_title, description=description)

# 動画のタイトルと概要欄から、カバー曲かどうかを判定する
if is_cover(video_title=video_title, description=description):
    # 動画のタイトルと概要欄から、オリジナルのアーティスト名を抽出する。取得できない場合は[]を返す
    artists: List[str] = extract_original_artists(video_title=video_title, description=description)

    # 動画の概要欄に、カバー元の動画のURLが含まれていたらそれを返し、なければNoneを返す
    original_url: Optional[str] = extract_original_url_in_YouTube(description=description)
else:
    artists = singers
    original_url = None
    
print(json.dumps({{song_title_key: song_title, singers_key: singers, artists_key: artists, original_url_key: original_url}}, indent=4, ensure_ascii=False)))
\```
上記のコードの実行をシミュレートしてコンソール出力を予想してください。
実装がない箇所は関数名から挙動を仮定しながら進めてください。
"""

    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": "You are a python simulator, which simulates the evaluation result of input"},
            {"role": "user", "content": context[1:-1]}
        ],
        max_tokens=1024,
        temperature=0,
    )
    
    return response.choices[0]['message']['content']
```

これ、プロンプト内のコードが一箇所間違っているのですが、気づいたでしょうか？

途中の

```python
# 動画のタイトルと概要欄から、曲名だけを抽出する。取得できない場合はNoneを返す
song_title: str = extract_song_title(video_title=video_title, description=description)

# 動画のタイトルと概要欄から、歌い手名を抽出する。取得できない場合は[]を返す
song_title: List[str] = extract_singers(video_title=video_title, description=description)
```

で、同じ変数を上書きしてしまっているので、この通り実行するとsong_title=歌い手名になってしまいます
しかし、実際の出力を見ると

```json
{
    "song_title": "浮遊感", 
    "singers": [
        "森中花咲", 
        "三枝明那"
    ], 
    "artists": [
        "harufuri"
    ], 
    "original_url": "https://youtu.be/sMFoXs2ts_8"
}
```

のように平然と期待通りのスキーマのJSOが出力されていました。

この辺り、ChatGPTの面白さが表れているような気がします。