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

# はじめに

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

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

本記事は「**実装編**」となっています。「前処理」のコードは前回の記事をご覧ください。
実装編の大まかな流れは以下のようになります。

1. ハイパーパラメータ / モデル / 損失関数 / 最適化方法　の設定
2. 学習コード
3. テストコード

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

# 使ったモデル

フレームワーク: Pytorch
モデル: Attentionを加えたseq2seq
形態素解析モジュール: jonome
環境: Google Colaboratory

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

seq2seqやAttentionの仕組みについては[前回の記事](https://qiita.com/bigshiny_0328/items/824270271639418ae6c9)をご覧ください。

今回のモデルの模式図は以下になります。 [参考論文](https://arxiv.org/pdf/1812.02303.pdf)

<img width="357" alt="スクリーンショット 2020-05-10 19.40.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/429781/640205a1-57d5-b771-5a04-6c260a0724f5.png">

ここで、SOSは"_"としています。

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

# 実装

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/bc121e8d-27ea-728d-4474-4bbdffa6a9d4.png">

なお、これら自作モジュールのコードは[github](https://github.com/bigshiny/yonedsu_lyric)に載せておりますのでご参照ください。

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

## 問題設定(前回の再掲)

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

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

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

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

## ハイパーパラメータ / モデル / 損失関数 / 最適化方法　の設定

ここで前回の内容に関するところで補足があります。
前回は、「入力テキスト」と「出力テキスト」を作ることが目標であり、それらは日本語でしたが、実はDLが読み込めるように`yonedu_dataset.prepare()`でID化(数値化)されています。

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

**ハイパーパラメータ**には上から次のように指定しています。

- encoderの埋め込みレイヤにおけるノード数
- encoderのLSTMレイヤにおける中間層のノード数
- バッチサイズ
- 米津さんがこれまで作詞された歌詞の語彙数(形態素解析にはjonomeを使用)
- 「空白」を表す単語ID

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

**モデル**はseq2seqということで、encoderとdecoderの二つに役割を分けます。

encoder: embedding layer + hidden layer with LSTM
decoder with attention: embedding layer + hidden layer with LSTM + attention system + softmax layer

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

**損失関数**にはクロスエントロピー誤差関数を使用し、**最適化方法**はencoderとdecoderともにAdamを使用しています。

また、モデルパラメータがあればそちらをロードする形になっています。

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

~~~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):]

# 前回はここまで

# ================================================
# ハイパーパラメータ設定 / モデル / 損失関数 / 最適化方法
# ================================================
# ハイパーパラメータ
embedding_dim = 200
hidden_dim = 128
BATCH_NUM = 100
EPOCH_NUM = 100
vocab_size = len(yonedu_dataset.word2id)  # 語彙数
padding_idx = yonedu_dataset.word2id[" "]  # 空白のID

# モデル
encoder = Encoder(vocab_size, embedding_dim, hidden_dim, padding_idx).to(device)
attn_decoder = AttentionDecoder(vocab_size, embedding_dim, hidden_dim, BATCH_NUM, padding_idx).to(device)

# 損失関数
criterion = nn.CrossEntropyLoss()

# 最適化方法
encoder_optimizer = optim.Adam(encoder.parameters(), lr=0.001)
attn_decoder_optimizer = optim.Adam(attn_decoder.parameters(), lr=0.001)

# 学習済みモデルがあれば,パラメータをロード
encoder_weights_path = "yonedsu_lyric_encoder.pth"
decoder_weights_path = "yonedsu_lyric_decoder.pth"
if os.path.exists(encoder_weights_path):
    encoder.load_state_dict(torch.load(encoder_weights_path))
if os.path.exists(decoder_weights_path):
    attn_decoder.load_state_dict(torch.load(decoder_weights_path))
~~~

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

## 学習コード

続いて学習コードになります。Attention付きのseq2seqは大体このようになるかと思いますが、一点だけ補足します。**自作のデータローダー**を使って、epochごとにバッチサイズ100個分のミニバッチを取得して、そのデータにおける合計の損失を逆伝播させ、勾配を取得し、パラメータを更新しています。

**自作のデータローダー**は、斎藤 康毅さんの[ゼロから作るディープラーニング３のソースコード](https://github.com/oreilly-japan/deep-learning-from-scratch-3)を参考にしています。

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

~~~python:main.py
# ================================================
# 学習
# ================================================
all_losses = []
print("training ...")
for epoch in range(1, EPOCH_NUM+1):
    epoch_loss = 0
    # データをミニバッチに分ける
    dataloader = SeqDataLoader(train_set, batch_size=BATCH_NUM, shuffle=False)

    for train_x, train_y in dataloader:

        # 勾配の初期化
        encoder_optimizer.zero_grad()
        attn_decoder_optimizer.zero_grad()

        # Encoderの順伝搬
        hs, h = encoder(train_x)

        # Attention Decoderのインプット
        source = train_y[:, :-1]

        # Attention Decoderの正解データ
        target = train_y[:, 1:]

        loss = 0
        decoder_output, _, attention_weight = attn_decoder(source, hs, h)
        for j in range(decoder_output.size()[1]):
            loss += criterion(decoder_output[:, j, :], target[:, j])

        epoch_loss += loss.item()

        # 誤差逆伝播
        loss.backward()

        # パラメータ更新
        encoder_optimizer.step()
        attn_decoder_optimizer.step()

    # 損失を表示
    print("Epoch %d: %.2f" % (epoch, epoch_loss))
    all_losses.append(epoch_loss)
    if epoch_loss < 0.1: break
print("Done")

import matplotlib.pyplot as plt
plt.plot(all_losses)
plt.savefig("attn_loss.png")

# モデル保存
torch.save(encoder.state_dict(), encoder_weights_path)
torch.save(attn_decoder.state_dict(), decoder_weights_path)
~~~

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

## テストコード

こちらがテストコードとなります。何をしているのかと言いますと、**[結果]**に表示しているテーブルを作っています。注意点は、以下の二つあります。

- テスト段階で予測するためなので、勾配は取得しない
- Decoderにはまず文字列生成開始を表す"_"をインプットにする(学習の際と同じ条件)

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

~~~python:main.py
# =======================================
# テスト
# =======================================
# 単語 -> ID 変換の辞書
word2id = yonedu_dataset.word2id
# ID -> 単語 変換の辞書
id2word = get_id2word(word2id)

# 一つの正解データの要素数
output_len = len(yonedu_dataset[0][1])

# 評価用データ
test_dataloader = SeqDataLoader(test_set, batch_size=BATCH_NUM, shuffle=False)

# 結果を表示するデータフレーム
df = pd.DataFrame(None, columns=["input", "answer", "predict", "judge"])
# データローダーを回して、結果を表示するデータフレームに値を入れる
for test_x, test_y in test_dataloader:
    with torch.no_grad():
        hs, encoder_state = encoder(test_x)

        # Decoderにはまず文字列生成開始を表す"_"をインプットにするので、
        # "_"のtensorをバッチサイズ分作成
        start_char_batch = [[word2id["_"]] for _ in range(BATCH_NUM)]
        decoder_input_tensor = torch.tensor(start_char_batch, device=device)

        decoder_hidden = encoder_state
        batch_tmp = torch.zeros(100,1, dtype=torch.long, device=device)
        for _ in range(output_len - 1):
            decoder_output, decoder_hidden, _ = attn_decoder(decoder_input_tensor, hs, decoder_hidden)
            # 予測文字を取得しつつ、そのまま次のdecoderのインプットとなる
            decoder_input_tensor = get_max_index(decoder_output.squeeze(), BATCH_NUM)
            batch_tmp = torch.cat([batch_tmp, decoder_input_tensor], dim=1)
        predicts = batch_tmp[:,1:]  # 予測されたものをバッチごと受け取る
        if test_dataloader.reverse:
            test_x = [list(line)[::-1] for line in test_x]  # 反転されたものをもどす
        df = predict2df(test_x, test_y, predicts, df)
df.to_csv("predict_yonedsu_lyric.csv", index=False)
~~~

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

# 結果

全問不正解。ですが、今回の目標は「**米津玄師さんの歌詞の特徴を捉えること**」でした。
表の一部を抜粋します。

**input**: 入力テキスト
**output**: 正解となる出力テキスト
**predict**: DLが予測したテキスト
**judge**: outputとpredictが合っているか

input  |  output  |   predict  |    judge
---------+----------------+----------------+------------
間違いか正解かだなんてどうでもよかった |瞬く間に落っこちた　淡い靄の中で|愛されたいのは　悲しくなるから　あなただけかもしらんが|X
その日から何もかも　変わり果てた気がした|風に飛ばされそでうな　深い春の隅|暖かい場所はまだ　今も　美しくあれるように|X
一つずつ　探し当てていこう|起きがけの　子供みたいに|枯れている　青い　その色さえも |X
今日がどんな日でも　何をしていようとも|僕はあなたを探してしまうだろう|変わらない街　探していたんだ|X

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

### わかったこと

- predictの文も意味不明ではない(「まだ 今も」のように文法は正確)
- inputからの文脈もそれほど外れてはいない
- ただ、米津さんの言葉選びの特徴を捉えられているかは正直微妙。

今回、過学習は見られなかったので、学習不足の原因は主にデータ数の少なさだと考えられる。
いや、「学習不足」と決めつけているのは私たちだけで、AIなりに考えていることがあるのかもしれません...