米津玄師さんの歌詞をAIに考えさせてみた(前処理編)

# はじめに

作曲する度に売れる米津玄師さん。
紡ぎ出される歌詞は人を魅了する力があるように思えます。
今回は、その魅力をディープラーニングに学習させてみようと思いました。

---------------------------------------

本記事は「データの前処理」までです。以下がその大まかな手順です。

1. スクレイピングして、米津さんが作詞した歌詞を全て取得
2. ディープラーニングの問題設定に沿うようにデータを整形
3. 8:2で訓練データとテストデータに分ける

一般的に「前処理」とは、正規化など精度向上のためのデータ処理を差しますが、今回の「前処理」とは、ディープラーニングの入力や出力となるように整形することを表します。

---------------------------------------

# 使ったモデル

フレームワーク: Pytorch
モデル: Attentionを加えたseq2seq

---------------------------------------

# seq2seqとAttention背景

「機械翻訳」に使われる手法の一つです。以下がseq2seqのイメージとなります。
![seq2seq.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/429781/73b9a9ad-d75d-92cc-253f-68e2e9c05a16.png)

引用記事: [Encoder-decoderモデルとTeacher Forcing，Scheduled Sampling，Professor Forcing](https://satopirka.com/2018/02/encoder-decoder%E3%83%A2%E3%83%87%E3%83%AB%E3%81%A8teacher-forcingscheduled-samplingprofessor-forcing/)

---------------------------------------

これによって Encoder 側で入力をエンコードした情報をもとに Decoder で文章生成できるのですが、実は若干の問題があります。それは、Decoderの入力を固定長のベクトルでしか表現できないというものです。Encoder の出力は隠れ層 $h$ なのですが、このサイズは固定です。それゆえ、入力系列の長さが長すぎるデータセットは、 $h$ に情報を適切に圧縮しきれず、入力系列の長さが短すぎるデータセットは、 $h$ にムダな情報を取り込んでしまいます。
そこで、 **Encoder の最後の隠れ層の状態だけでなく、途中の隠れ層の状態も利用** したくなってきます。

これが、Attention が考案された背景です。

---------------------------------------

# Attention機構

Attentionとは、時系列データを扱う際に過去の重要なポイントに注意を払う(=Attention)ための手法です。今回では、ある曲の「歌詞の一節」に対して「次の一節」を予測するので、**次の一節を予測するために、前回の一節のどこに注目すればいいか**といった形になります。以下がAttentionのイメージです。

<div align="center">
<img width="400" alt="attention.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/429781/1d34955c-2c1a-48e6-c3a1-ae46b00e86ad.png">
</div>

source: [Effective Approaches to Attention-based Neural Machine Translation](https://arxiv.org/abs/1508.04025)

---------------------------------------

参考論文によると、正確には Global Attention model と呼ばれます。Encoderの全ての隠れ状態をベクトルとしてまとめ、それらとDecoderの出力の内積をとることで、**「Encoderの全ての隠れ状態とDecoderの出力の類似度」**が得られます。この類似度を内積により測ることが「重要な要素に注目している」というAttentionと呼ばれる所以です。

---------------------------------------

# 実装

Google colabに必要な自作モジュールをアップロードした後、
後ほど記載するmain.pyをコピペして実行。

**必要な自作モジュール**
<img width="299" alt="スクリーンショット 2020-05-09 18.28.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/429781/01732b43-23e7-b588-b1ea-111c257d264d.png">

---------------------------------------

## 問題設定

以下のように、米津玄師さんがこれまでに出された曲の「一節」から「次の一節」を予測します。

|入力テキスト| 出力テキスト |
|-------+-------|
|あたしあなたに会えて本当に嬉しいのに | _当たり前のようにそれらすべてが悲しいんだ |
|当たり前のようにそれらすべてが悲しいんだ| _今　痛いくらい幸せな思い出が |
|今　痛いくらい幸せな思い出が| _いつか来るお別れを育てて歩く|
|いつか来るお別れを育てて歩く| _誰かの居場所を奪い生きるくらいならばもう|

これは[歌詞ネット](https://www.uta-net.com/)からスクレイピングさせていただき作成いたしました。

---------------------------------------

## データ準備

### スクレイピング

下記コードにてスクレイピングにより、歌詞を取得する
なお、これらはGoogle Colabで実行。

~~~python
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.select import Select
import requests
from bs4 import BeautifulSoup
import re
import time

# setting
# Seleniumをあらゆる環境で起動させるChromeオプション
options = Options()
options.add_argument('--disable-gpu');
options.add_argument('--disable-extensions');
options.add_argument('--proxy-server="direct://"');
options.add_argument('--proxy-bypass-list=*');
options.add_argument('--start-maximized');
options.add_argument('--headless');

class DriverConrol():
    def __init__(self, driver):
        self.driver = driver
        
    def get(self, url):
        self.driver.get(url)
        
    def get_text(self, selector):
        element = self.driver.find_element_by_css_selector(selector)
        return element.text
        
    def get_text_by_attribute(self, selector, attribute='value'):
        element = self.driver.find_element_by_css_selector(selector)
        return element.get_attribute(attribute)
    
    def input_text(self, selector, text):
        element = self.driver.find_element_by_css_selector(selector)
        element.clear()
        element.send_keys(text)
        
    def select_option(self, selector, text):
        element = driver.find_element_by_css_selector(selector)
        Select(element).select_by_visible_text(text)
        
    def click(self, selector):
        element = self.driver.find_element_by_css_selector(selector)
        element.click()
        
    def get_lyric(self, url):
        self.get(url)
        time.sleep(2)
        element = self.driver.find_element_by_css_selector('#kashi_area')
        lyric = element.text
        return lyric
    
    def get_url(self):
        return self.driver.current_url
        
    def quit(self):
        self.driver.quit()

BASE_URL = 'https://www.uta-net.com/'
search_word = '米津玄師'
search_jenre = '作詞者名'
driver = webdriver.Chrome(chrome_options=options)
dc = DriverConrol(driver)
dc.get(BASE_URL) # アクセス

# 検索
dc.input_text('#search_form > div:nth-child(1) > input.search_input', search_word)
dc.select_option('#search_form > div:nth-child(2) > select', search_jenre)
dc.click('#search_form > div:nth-child(1) > input.search_submit')
time.sleep(2)

# requestsで一気にurlを取得
response = requests.get(dc.get_url())
response.encoding = response.apparent_encoding  # 文字化け対策
soup = BeautifulSoup(response.text, "html.parser")
side_td1s = soup.find_all(class_="side td1")  # classがside td1のtd要素を全て取得
lyric_urls = [side_td1.find('a', href=re.compile('song')).get('href') for side_td1 in side_td1s]  # side_td1sに含まれる, hrefに''songが含まれるaタグのhref要素を取得
music_names = [side_td1.find('a', href=re.compile('song')).text for side_td1 in side_td1s]  # 全曲名を取得

# 歌詞を取得して、lyric_lisに追加していく
lyric_lis = list()
for lyric_url in lyric_urls:
    lyric_lis.append(dc.get_lyric(BASE_URL + lyric_url))
with open(search_word + '_lyrics.txt', 'wt') as f_lyric, open(search_word + '_musics.txt', 'wt') as f_music:
    for lyric, music in zip(lyric_lis, music_names):
        f_lyric.write(lyric + '\n\n')
        f_music.write(music + '\n')
~~~

---------------------------------------

**取得された歌詞を一部抜粋**

~~~
あたしあなたに会えて本当に嬉しいのに
当たり前のようにそれらすべてが悲しいんだ
今　痛いくらい幸せな思い出が
いつか来るお別れを育てて歩く

誰かの居場所を奪い生きるくらいならばもう
あたしは石ころにでもなれたならいいな
だとしたら勘違いも戸惑いもない
そうやってあなたまでも知らないままで

...
~~~

---------------------------------------

### データ整形

現状は[問題設定]で示したデータには程遠いので「データの整形」を行う。

つまり、この処理を行います。

<img width="862" alt="スクリーンショット 2020-05-09 19.01.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/429781/4b8c9dda-0f41-ee9a-137c-fbd4d97d038f.png">

---------------------------------------

以下のコードでデータ整形を行う
わかりづらいコードにはなりますが、一応これで前処理は完了です。

~~~python:main.py
from datasets import LyricDataset
import torch
import torch.optim as optim
from modules import *
from device import device
from utils import *
from dataloaders import SeqDataLoader
import math
import os
from utils

# ==========================================
# データ用意
# ==========================================
# 米津玄師_lyrics.txtのパス
file_path = "lyric/米津玄師_lyrics.txt"
edited_file_path = "lyric/米津玄師_lyrics_edit.txt"

yonedu_dataset = LyricDataset(file_path, edited_file_path)
yonedu_dataset.prepare()
# check
print(yonedu_dataset[0])

# 8:2でtrainとtestに分ける
train_rate = 0.8
data_num = len(yonedu_dataset)
train_set = yonedu_dataset[:math.floor(data_num * train_rate)]
test_set = yonedu_dataset[math.floor(data_num * train_rate):]
~~~

---------------------------------------

~~~python:datasets.py
from sklearn.model_selection import train_test_split
from janome.tokenizer import Tokenizer
import torch
from utils import *

class LyricDataset(torch.utils.data.Dataset):
    def __init__(self, file_path, edited_file_path, transform=None):
        self.file_path = file_path
        self.edited_file_path = edited_file_path
        self.tokenizer = Tokenizer(wakati=True)

        self.input_lines = []  # NNの入力となる配列(それぞれの要素はテキスト)
        self.output_lines = []  # NNの正解データとなる配列(それぞれの要素はテキスト)
        self.word2id = {}  # e.g.) {'word0': 0, 'word1': 1, ...}

        self.input_data = []  # 一単語一単語がID化された歌詞の一節
        self.output_data = []  # 一単語一単語がID化された次の一節

        self.word_num_max = None
        self.transform = transform

        self._no_brank()

    def prepare(self):
        # NNの入力となる配列(テキスト)とNNの正解データ(テキスト)となる配列を返す
        self.get_text_lines()

        # date.txtで登場するすべての文字にIDを割り当てる
        for line in self.input_lines + self.output_lines:  # 最初の一節とそれ以降の一節
            self.get_word2id(line)

        # 一節の単語数の最大値を求める
        self.get_word_num_max()
        # NNの入力となる配列(ID)とNNの正解データ(ID)となる配列を返す
        for input_line, output_line in zip(self.input_lines, self.output_lines):
            self.input_data.append([self.word2id[word] for word in self.line2words(input_line)] \
            + [self.word2id[" "] for _ in range(self.word_num_max - len(self.line2words(input_line)))])
            self.output_data.append([self.word2id[word] for word in self.line2words(output_line)] \
            + [self.word2id[" "] for _ in range(self.word_num_max - len(self.line2words(output_line)))])

    def _no_brank(self):
        # 行の間の空白を取る
        with open(self.file_path, "r") as fr, open(self.edited_file_path, "w") as fw:
            for line in fr.readlines():
                if isAlpha(line) or line == "\n":
                    continue  # 英字と空白は飛ばす
                fw.write(line)

    def get_text_lines(self, to_file=True):
        """
        空行なしの歌詞ファイルのパスfile_pathを受け取り、次のような配列を返す
        """
        # 米津玄師_lyrics.txtを1行ずつ読み込んで「歌詞の一節」と「次の一節」に分割して、inputとoutputで分ける
        with open(self.edited_file_path, "r") as f:
            line_list = f.readlines()  # 歌詞の一節...line
            line_num = len(line_list)
            for i, line in enumerate(line_list):
                if i == line_num - 1:
                    continue  # 最後は「次の一節」がない
                self.input_lines.append(line.replace("\n", ""))
                self.output_lines.append("_" + line_list[i+1].replace("\n", ""))

        if to_file:
            with open(self.edited_file_path, "w") as f:
                for input_line, output_line in zip(self.input_lines, self.output_lines):
                    f.write(input_line + " " + output_line + "\n")


    def line2words(self, line: str) -> list:
        word_list = [token for token in self.tokenizer.tokenize(line)]
        return word_list

    def get_word2id(self, line: str) -> dict:
        word_list = self.line2words(line)
        for word in word_list:
            if not word in self.word2id.keys():
                 self.word2id[word] = len(self.word2id)

    def get_word_num_max(self):
        # 長さが最大のものを求める
        word_num_list = []
        for line in self.input_lines + self.output_lines:
            word_num_list.append(len([self.word2id[word] for word in self.line2words(line)]))
        self.word_num_max = max(word_num_list)

    def __len__(self):
        return len(self.input_data)

    def __getitem__(self, idx):
        out_data = self.input_data[idx]
        out_label = self.output_data[idx]

        if self.transform:
            out_data = self.transform(out_data)

        return out_data, out_label
~~~

---------------------------------------

# 今回は前処理まで

思ったよりコードが長くなりそうですので、今回は「データの前処理」までとさせていただきます。