BoWとDoc2Vecの結果の比較をしてみる ~aikoの楽曲同士で歌詞が似ているものを出す~

## はじめに
Pythonを用いてスクレイピング→自然言語処理の一連の流れを行なったので、自身の備忘録兼自分のような初学者の方の何か参考になればと思い記事を投稿します。

【環境】
・Python: 3.7.6
・Mac OS: Catalina 10.15.7

## 行ったこと
カウントベースの手法であるBoW(Bag of Words)と、Doc2Vecでどのように結果に違いが出るのか気になったので、私の好きな歌手のaikoの歌詞を用いてどの楽曲の歌詞がどれと近いのか(コサイン類似度が高いのか)を2つの手法で見てみました。
流れは以下の通りです。

①スクレイピングでのデータの入手
②データの前処理
③BoW(Bag of Words)での歌詞のベクトル化
④Doc2Vecでの歌詞のベクトル化
⑤BoWとDoc2Vecの結果の比較
⑥所感

## ①スクレイピングでのデータの入手
まずはデータの入手をします。歌詞は[歌ネット](https://www.uta-net.com/)からスクレイピングで入手しました。
コードは以下の通りです。
(2020年12月2日現在、利用規約上スクレイピングやリンクの貼り付けは問題なさそうでしたが、最新の状況については都度ご確認ください。)

``````sample.py
import re
import time
import requests
import pandas as pd
from bs4 import BeautifulSoup

# n_of_pagesに楽曲一覧のページ数を、base_urlにアーティストの歌詞一覧URLを入力する
def get_lyrics(n_of_pages, base_url):
    # タイトルと歌詞を格納する箱を作る
    title_list = []
    lyrics_list = []
    
    # 楽曲一覧のループ
    for number in range(1,n_of_pages+1):
        url = base_url + '0/' + str(number) + '/'
        response = requests.get(url)
        soup = BeautifulSoup(response.text, 'lxml')

        # 楽曲一覧のページから各曲のページに飛ぶためのURLを取得する
        additional_urls = soup.find_all('td', class_ = 'side td1')

        # 各楽曲のページに飛びながら曲名と歌詞を取得する
        for additional_url in range(len(additional_urls)):
            # 取得時間の表示のために計測をする
            start_time = time.time()

            # 歌ネットのベースとなるURL：https://www.uta-net.com
            # 各ページのURLを作成
            page = 'https://www.uta-net.com' + additional_urls[additional_url].a.get('href')

            response = requests.get(page)
            soup = BeautifulSoup(response.text, 'lxml')

            #タイトルの取得
            title = soup.find('h2').text
            title_list.append(title)

            # 歌詞の取得
            lyrics = soup.find('div', id = 'kashi_area').text
            lyrics_list.append(lyrics)

            # 取得にかかった時間の表示
            print('{}ページ目{}曲目:'.format(str(number), str(additional_url+1)) + str(time.time() - start_time) + '秒')

            # サーバーへの負荷を抑えるために待ち時間を作る
            time.sleep(1)

    # DataFrame化する
    lyrics_df = pd.DataFrame()
    lyrics_df['title'] = title_list
    lyrics_df['lyrics'] = lyrics_list
    
    return lyrics_df
```

上記コードを実行すると以下のようになります。
![スクレイピング実行.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918942/af6201c6-f8c0-8a15-c89b-8ef387560831.png)


入手した歌詞のデータフレームは以下の通りです。
「相合傘」と「相合傘(汗かきMix)」はアレンジ違いの同じ曲のため「相合傘(汗かきMix)」は削除してインデックスを振り直します。(コード略)
![スクレイピング結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918942/83296376-de10-4924-0c74-d2b27620469c.png)


## ②データの前処理
文字の正規化 → 不要な文字の削除や表記ブレの統一 → 分かち書き、の順番で前処理をします。分かち書きのための形態素解析のエンジンにはMeCabを、辞書はNEologdを使用しています。

下記のコードの分かち書きの関数では助詞と助動詞を取り除いております。ただ、分析対象によっては名詞と形容詞以外は取り除くこともあるなど、どの品詞を使うのが最適であるかは一概には言えません。
今回は一見それほど意味のないと思われる単語でも、歌詞という表現においては作詞者の意図があるのではという想定の元、助詞と助動詞以外は残すこととしました。

``````sample.py
import MeCab
import unicodedata

# unicode正規化の関数
def uni_seikika(text):
  return unicodedata.normalize("NFKC", text)

# 不要な文字の削除や表記ブレの統一
def preprocess(text):
    # 改行、半角スペース、全角スペースを削除
    text = re.sub('\r', '', text)
    text = re.sub('\n', '', text)
    text = re.sub('　', '', text)
    text = re.sub(' ', '', text)

    # 数字とアルファベットの統一
    text = re.sub(r'[０-９]', r'[0-9]', text)  # 数字を半角に
    text = text.lower() # アルファベットを全て小文字に
        
    # その他特殊文字の削除
    text = re.sub(r'[\．_－―─!！＠＃＄％＾＆\-‐|\\＊\“（）・＿■×+α※÷⇒—●★☆〇◎◆▼◇△□♬(：〜～＋=)／*&^%$#@!~ω`´){}［］…\[\]\"\'\”\’:;<>?＜＞〔〕〈〉?？、。・,\./『』【】「」→←○《》≪≫]+', "", text)
    
    return text

# MeCabのインスタンスを作成。辞書はNEologdを使用
tagger = MeCab.Tagger('-Owakati -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd')

# 分かち書きの関数。助詞と助動詞は取り除く
def wakati(text):
  result = []
  node = tagger.parseToNode(text)
  while node:
    if (not node.feature.startswith("BOS/EOS")) and (not node.feature.startswith("助詞")) and (not node.feature.startswith("助動詞")):
      result.append(node.surface)
    node = node.next
  return " ".join(result)

# これまでの関数をまとめて前処理の関数とする
def preprocess_and_wakati(text):
  text = uni_seikika(text)
  text = preprocess(text)
  text = wakati(text)
  return text
```

上記のコードを歌詞に適用すると以下のような結果となります。
![分かち書き.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918942/8f3951df-e590-d31e-9ffb-22b47476806a.png)

## ③BoW(Bag of Words)での歌詞のベクトル化
ここから2つの手法で歌詞をベクトル化していきます。
まずはBoWです。CountVectorizerでベクトル化の後に、TF-IDFでの単語の重要度の補正をしています。

``````Sample.py
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfTransformer

# bag of wordsの作成
count = CountVectorizer()
docs = aiko_lyrics["wakati"]
bag = count.fit_transform(docs)

# TF-IDFによりベクトルの重みを補正する
tfidf = TfidfTransformer(use_idf = True, norm = 'l2', smooth_idf = True)
tfidf_array = tfidf.fit_transform(bag).toarray()

# 2次元配列になっているため、1次元配列にする。
temp = []
for i in range(len(tfidf_array)):
    temp.append(tfidf_array[i].flatten())
tfidf_array = temp.copy()
```

楽曲名を入力するとコサイン類似度が高い楽曲を出す関数を作っておきます。

``````Sample.py
import numpy as np

# コサイン類似度を算出する関数
def cos_sim(v1, v2):
    return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))

# 楽曲のインデックス場号を取得する関数
def title_number(name):
    return aiko_lyrics.query('title == "{}"'.format(name)).index[0]

# 楽曲名を入力すると歌詞が似ている楽曲を出力する関数
def similar_lyrics_bow(name):
    # 指定した楽曲のベクトルを抽出
    vector1 = tfidf_array[title_number(str(name))]
    
    #空のデータフレームの用意
    cos_list = []
    
    #全楽曲のコサイン類似度を出す
    for i in range(len(tfidf_array)):
        vector2 = tfidf_array[i]
        temp = cos_sim(vector1, vector2)
        cos_list.append(temp)
    
    # データフレームの作成
    df = pd.DataFrame(columns=["title", "cosine_similarity"])
    df["title"] = aiko_lyrics["title"]
    df["cosine_similarity"] = cos_list
    answer = df.sort_values("cosine_similarity", ascending = False).reset_index()["title"][1] # 0は入力した楽曲自身が出てくるため1を取得
    
    return answer
```

結果は後ほど。

## ④Doc2Vecでの歌詞のベクトル化
続いてDoc2Vecです。処理の都合上分かち書きの結果がリスト形式になっている必要があるため、分かち書きのデータを作り直します。

``````Sample.py
# 前処理の関数を修正。
def preprocess_and_wakati_list(text):
  text = uni_seikika(text)
  text = preprocess(text)
  text = wakati(text)
  text = text.strip().split() # 変更点。分割してリスト化
  return text
```
上記関数で分かち書きをすると以下のようにリスト化されます。
![分かちリスト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918942/3fa97e1e-6150-fb4b-24f1-de77a38d52cc.png)

Doc2Vecでのベクトル化を行います。

``````Sample.py
from gensim.models.doc2vec import Doc2Vec
from gensim.models.doc2vec import TaggedDocument

# Doc2Vecでの学習では[(['単語','単語','単語'], [tag]), ]のような形式にする必要がある。TaggedDocumentを使い作成する。
d2v_doc = []
for lyrics, title in zip(aiko_lyrics['wakati'], aiko_lyrics['title']):
    d2v_doc.append(TaggedDocument(words = lyrics, tags = [title]))

# モデルの作成
model_d2v = models.Doc2Vec(documents = d2v_doc,
                           vector_size = 30,
                           window = 5,
                          min_count = 0, # 今回は低頻度単語を消さずにやってみます
                          iter = 100)

```

Doc2Vecでも楽曲名を入力するとコサイン類似度が高い楽曲を出す関数を作っておきます。

``````Sample.py
# 歌詞が似ている曲を抽出する関数
def similar_lyrics_d2v(name):
    return model_d2v.docvecs.most_similar(str(name))[0][0]
```
## ⑤BoWとDoc2Vecの結果の比較
それではそれぞれのモデルで結果を見てみます。

BoWの場合
![BoW結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918942/f008fdea-6ab0-4a4e-a310-5c30c5d9d9f0.png)

Doc2Vecの場合
![Doc2Vec結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918942/4a6236be-9357-7dca-ac61-ec3afb7798e6.png)

別の曲が出てきますね。

## ⑥所感
それぞれの手法で違う結果となりました。
BoWではカウントベースの手法のため、同じ単語が含まれる楽曲が選ばれています。例えば「キラキラ」と「あなたは」では「遠い」「離れ」「雨」などが共通しています。
一方、Doc2Vecは意味合いが近しい曲が選ばれた印象です。「アンドロメダ」と「卒業式」などは確かになんとなく近いような気がしなくもないです。

最後まで読んで頂きありがとうございました。