歌ってみた動画のタイトルから楽曲名を取得する

# 背景
* 歌ってみた動画の分析をする際に、「どの楽曲か」の情報は重要です。
* しかし、YouTubeの場合、動画に楽曲タイトルが紐づいてないこともあります。
* そこから楽曲タイトルを見つける部分を手動でやるのが辛いのでなんとかしようと思いました。

# コード
普通に正規表現とかを使って処理しました。
(下の方に改善版のコードがあるので、コピペする前に一番下までスクロールすることを推奨します）

``` get_song_titles.py

def get_song_title(raw_title):

    # ()（）[]【】を除外する。左が半角で右が全角だったりすることもある。
    title = re.sub("[【（\(\[].+?[】）\)\]]","",raw_title)

    # 「」と『』がある場合、その中の文字列を取り出す
    if "「" in title and "」" in title:
        title = title.split("「")[1].split("」")[0]

    if "『" in title and "』" in title:
        title = title.split("『")[1].split("』")[0]

    # XをYで歌ってみた, 歌ってみた を消す
    title = re.sub("を.*歌ってみた","",title)
    title = title.replace("歌ってみた", "") 

    # cover, covered, covered by 以降の文字列を消す
    title = re.sub("[cC]over(ed)?( by)?.*", "", title)
 
    # 最後の/以降は削除する
    if "/" in title:
        title = "/".join(title.split("/")[:-1])
    
    if "／" in title:
        title = "／".join(title.split("／")[:-1])
  
    # - が1個だけあったらその後ろを消す
    if len(title.split("-")) == 2: 
        title = title.split("-")[0]

    # コラボメンバーを×で表現している部分を消す
    title_part_list = []
    for title_part in title.split(" "):
        if "×" not in title_part:
            title_part_list.append(title_part)
    title = " ".join(title_part_list)

    title = title.strip()

    return title


# 後述するデータを読み込んで使用する場合の例
if __name__ == "__main__":
    x = []
    y = []

    with open("sing_videos.tsv") as f:    
        for line in f:
            x.append(line.strip().split("\t")[1])

    # 1行目はヘッダーなので削除
    del x[0]

    estimated_titles = [ get_song_titles(x) for raw_title in x ]
```

# 性能評価

## データ

以前の記事 ( https://qiita.com/miyatsuki/items/fb933bb233d2896ca644 ) で集めた、歌ってみた動画のメタデータを使用し、手動でラベリングしました。GitHubにデータをあげたので、追試等で必要であればこちらを参照してください。
https://github.com/miyatsuki/VTuberNayoseDataset/blob/57fe0d785b40c19fa7b249034bdfe1fa62363743/data/sing_videos.tsv

## 結果

動画数: 277
正答率: 92.42% (256/277)

大したことしてませんが、一応9割は超えました。
結果を見ながらチューニングしてのスコアなので、未知の動画に対してここまでの精度が出るかは不明です。

## ダメだったもの

* 必要な情報を消しすぎるパターン
    * 楽曲タイトルに/が入っている場合はほぼ、()が入っている場合は確実に失敗します
    * 【】内にタイトルが入っている場合もあり、これも確実に失敗します
* 不要な情報を消せないパターン
    * 誤字・脱字などのtypoに弱いです
    * ♡などのタイトルに関係ない記号が入っている場合は失敗します
    * その他楽曲タイトル以外の情報が多いと失敗しやすいです

| 動画タイトル | 推定されたタイトル | 正解 |
| --- | --- | --- |
|Natto!! -Moon!!(月ノ美兎/iru）替え歌- 歌ってみた【卯月コウ】|Natto!! -Moon!!替え歌-|Moon!!|
|Disney Medley｜covered by 戌亥とこ|Disney Medley｜|Disney Medley|
|【Virtual to LIVE（covered by #さんばか）】活動半年ありがとう【にじさんじ】|】活動半年ありがとう|Virtual to LIVE|
|【歌ってみた】さらばかゆみ with 剣持【股間戦士エムズーン】|さらばかゆみ with 剣持|さらばかゆみ|
|【恋愛サーキュレーション】歌ってみた。 Renai Circulation - Bakemonogatari Cover By Utako suzuka||恋愛サーキュレーション|
|【ユキトキ】cover.える【試聴動画】||ユキトキ|
|【歌ってみた】アニメ「ダンベル何キロ持てる？」OP　お願いマッスル【シスター・クレア×花畑チャイカ】|ダンベル何キロ持てる？|お願いマッスル|
|【替え歌】妖怪のせいにしないようかい体操第一【歌ってみた物述】|妖怪のせいにしないようかい体操第一|ようかい体操第一|
|【オリジナルMV】緑仙君と鈴鹿ウタでシンデレラガール / King&Prince 歌ってみた【cover】|緑仙君と鈴鹿ウタでシンデレラガール|シンデレラガール|
|⚙*.。..からくりピエロ Karakuri Pierrot/雪城眞尋【歌ってみた】|⚙*.。..からくりピエロ Karakuri Pierrot|からくりピエロ|
|【天気の子】グランドエスケープ (Movie edit) feat.三浦透子 - Covered by 町田ちま＆ぴろぱる|グランドエスケープ  feat.三浦透子|グランドエスケープ (Movie edit) feat.三浦透子|
|【LOL部】VD&GでBlessing歌ってみた【替え歌】|VD&GでBlessing|Blessing|
|【1周年記念】けいおん!!のU＆I　歌ってみた【童田明治】|けいおん!!のU＆I|U＆I|
|♡future base歌ってみた♡|♡future base|future base|
|【ヨルシカ】唐突に雨とカプチーノ歌ってみた【物述有栖】|唐突に雨とカプチーノ|雨とカプチーノ|
|音量注意】古書屋敷殺人事件歌ってみた/鷹宮リオン|音量注意】古書屋敷殺人事件|古書屋敷殺人事件|
|白日 / King Gnu (Covered by 夢追翔) 日本テレビ系「イノセンス 冤罪弁護士」主題歌【歌ってみた】【カバー】キングヌー|イノセンス 冤罪弁護士|白日|
|【歌ってみた】ハロ/ハワユ【手描きPV】|ハロ|ハロ/ハワユ|
|【JK弾き語り】オラシオン弾いて歌ってみた【物述有栖】|オラシオン弾いて|オラシオン|
|【歌ってみた】赤い罠(who loves it?) / LiSA【樋口楓cover】|赤い罠|赤い罠(who loves it?)|
|【君の名は。】 なんでもないや / RADWIMPS (cover)鈴鹿詩子【聖地でのオリジナルPV】Nandemonaiya "Your Name"/Utako Suzuka|なんでもないや / RADWIMPS 鈴鹿詩子Nandemonaiya "Your Name"|なんでもないや|


# 追試

データ数を増やして精度を再度調査しました

## データ

対象となる歌い手(全てVTuber)を増やして、改めてデータ収集をしました（対象動画数が4.8倍になりました)。
https://github.com/miyatsuki/VTuberNayoseDataset/commit/576b89b5c8a6f74744cb24c62a5d8cb77a736ea7

## 結果

動画数: 1335
正答率: 75.95% (1014/1335)

## ロジックの調整

正答率がガッツリ下がったので、やや特殊なパターンにも対応させてみました。
また、同一の楽曲を他の人が歌っている かつ そちらはタイトルを正しく取得できている というパターンがあるので、その情報を拾えるように他の推定結果を活用するようにしました。

``` python
import pandas as pd
import re

def get_song_title(raw_title):
    
    # 「作品名」より【楽曲タイトル】 というパターンがあるので、その場合は【】の中身をタイトルとする
    if "より【" in raw_title:
        title = raw_title.split("【")[1].split("】")[0]
    else:
        title = raw_title
    
    # ヘッダー的に記号がついていたら削除する
    if title[0] == "★":
        title = title[1:]
    
    # ()（）[]【】を除外する。左が半角で右が全角だったりすることもある
    title = re.sub("[【（《\(\[].+?[】）》\)\]]"," ",title)

    # 「作品名」主題歌 などのパターンの場合は、その部分を消す
    for keyword in ["主題歌", "OP", "CMソング"]:
        if "」{}".format(keyword) in title:
            end_index = title.index("」{}".format(keyword))
            for start_index in range(end_index, -1, -1):
                if title[start_index] == "「":
                    title = title[:start_index] + title[end_index + len(keyword) + 1:]
                    break
        
    for keyword in ["主題歌", "OP", "CMソング"]:
        if "』{}".format(keyword) in title:
            end_index = title.index("』{}".format(keyword))
            for start_index in range(end_index, -1, -1):
                if title[start_index] == "『":
                    title = title[:start_index] + title[end_index + len(keyword) + 1:]
                    break
        
    # 「」と『』がある場合、その中の文字列を取り出す
    # ただし、稀に「」の中に自分の名前を入れている場合がある。その場合は無視する
    if "「" in title and "」" in title:
        temp_title = title = title.split("「")[1].split("」")[0]
        if "cover" not in temp_title.lower():
            title = temp_title

    if "『" in title and "』" in title:
        temp_title = title.split("『")[1].split("』")[0]
        if "cover" not in temp_title.lower():
            title = temp_title

    # 歌ってみた以降の文字列を消す
    title = re.sub("を歌ってみた.*"," ", title)
    title = re.sub("歌ってみた.*"," ", title)

    # cover, covered, covered by 以降の文字列を消す
    title = re.sub("[cC]over(ed)?( by)?.*", "", title)

    # /以降は削除する
    if "/" in title:
        title = title.split("/")[0]

    if "／" in title:
        title =  title.split("／")[0]

    # - があったらその後ろを消す
    title = title.split("-")[0]

    # コラボメンバーを×で表現している部分を消す
    # #012 的な表現を消す
    title_part_list = []
    for title_part in title.split(" "):
        if "×" not in title_part and not re.fullmatch("#[0-9]+", title_part):
            title_part_list.append(title_part)
    title = " ".join(title_part_list)

    # 前後の空白を削除
    title = title.strip()

    return title


#　動画タイトルと楽曲のタイトル(推定値)を比較し、部分一致した楽曲タイトルのうち最も長いものを返す
def get_nearest_title(video_title, music_titles):
    longest = 0
    ans = ""
    
    for music_title in music_titles:
        if len(music_title) <= longest:
            continue
        
        if music_title in video_title:
            ans = music_title
            longest = len(music_title)
    
    return ans


def decide_title(row):
    return row["estimated_title"] if len(row["estimated_title"]) > 0 else row["estimated_title2"]    


if __name__ == "__main__":

    evaluate_df = pd.read_table("sing_videos.tsv")
    evaluate_df["estimated_title"] = evaluate_df["video_title"].apply(get_song_title)

    # 正規表現での推定結果が空文字になってしまう場合は、他の動画の推定結果から最もそれらしいものを探す
    evaluate_df["estimated_title2"] = evaluate_df["video_title"].apply(
        get_nearest_title, music_titles = evaluate_df["estimated_title"].unique()
    )
    evaluate_df["estimated_title"] = evaluate_df.apply(decide_title, axis=1)
    evaluate_df = evaluate_df.drop(columns=["estimated_title2"])
```

## 結果(ロジック修正後)

動画数: 1335
正答率: 85.24% (1138/1335)