PyTorchでBack Numberっぽい歌詞を生成してみた件
今から非常に重要なことを伝えます。
国民的バンドグループBack Numberは群馬県出身です。
(彼らは群馬県のことを全く押し出してませんが、代わりに私が押し出します。)
ということで「ゼロから作るDeep Learning 自然言語処理編」を読んだので、本書で記載のあるRNNについて、まとめてみます。そして最後に歌詞生成してみようと思います。
まずは言語モデルについて
言語モデルとは、「単語の並びに対して、その並び順がどれだけ有り得るのか」に対しての確率を与えます。
ここでm個の単語からなる文章の確率を、
𝑃(𝑤1,・・・,𝑤𝑚)と表します。
ここで確率の乗法定理 𝑃(𝐴,𝐵)=𝑃(𝐴|𝐵)𝑃(𝐵)を用いて、
何故このように変形できるかというと、
と言う手順を繰り返すからです。
ここで大事なのは∏𝑡=1𝑚𝑃(𝑤𝑡|𝑤1,・・・,𝑤𝑡−1)とあるように、m個の単語からなる文章の確率は、m番目より左側全ての単語を利用することで求まるということです。
ここで以下の???を当てるモデルを考えましょう。
「Tom was watching TV in his room. John came into the room. And John popped the question to ???」
という文章があった場合、???には「him」が入ります。
そして???に「him」が入るかどうかは、文頭のTomがわかっていなければいけません。
ここでCBOWモデルを使って、???を予測する場合を考えましょう。
上記の???を予測する時は、CBOWモデルのwindowサイズは「Tom」までを含んでいる18単語に設定しなければいけません。
このようにしてCBOWモデルを利用して予測する場合は、windowサイズできるだけ大きく設定する必要が有ります。ではwindowサイズできるだけ大きく設定すれば、問題は解決するのでしょうか?答えな否です。
CBOWモデルは周辺単語の単語ベクトルの「和」が中間層にきます。
したがって文章の単語の並び方が無視されます。
参考:https://blog.hoxo-m.com/entry/2020/02/20/090000
そこで登場するモデルがRNNです。
RNNは文章がどれだけ長くとも、文章の情報を並び方含め記憶する構造を持ちます。そのためCBOWモデルの時のようなwindowサイズを気にする必要もなくなります!
RNNとは?
RNNで実施したい内容は、t番目の単語𝑤𝑡から、t+1番目の単語𝑤𝑡+1の単語を当てるということです。「you say hey. I say ho.」という文章があった時に、「you」から「say」を当てて、「say」から「ho」を当てるとうイメージです。
ここで各時刻のRNNレイヤは、そのレイヤへの入力と一つ前のRNNレイヤからの出力を受け取ります。つまり入力が「say」のRNNレイヤは、「say」のベクトルと、「you」のRNNレイヤの出力を受け取ります。
sayの出力 = tahn(youのRNNレイヤの出力・重み_1 + sayのベクトル・重み_2 + b)
ここで言う「重み_1」は、youのRNNレイヤの出力を、sayの出力に変換するための重みで、「重み_2」はsayのベクトルを出力に変換するための重みです。このような構造を持つことによって、𝑥_𝑡の入力から𝑥_𝑡+1を予測する時に、𝑥_𝑡までの全ての単語を考慮した上で𝑥_𝑡+1を予測することができるのです。
RNNの学習時の問題?
RNNでは、ディープラーニングと同じように学習することができますが、例えばめちゃめちゃ長い文章があった時などは計算リソースにおける問題が出てきます。何故ならば単語数が多ければ多い分だけ、下記ネットワークが右に長くなり、誤差逆伝播(時間方向に展開したニューラルネットワークの誤差逆伝播法なので、Backpropagation Throu time 略して BPTT)におけて計算リソースが必要になるからです。
そこで出てくるのがtruncated BPTTです。
これは誤差逆伝播を、ネットワーク全体で行うのではなく、適当なブロックにまとめて行おうと言うことです。
RNNの損失関数
RNNの学習方法をお伝えしたので、損失関数について気になった方がいらっしゃると思います。損失関数は、ある文章(もしくは文章のブロック)における平均をとることでもとまります。
モデルの評価について
言語モデルの予測性能のよさを評価する指標としては、perplexity(パープレキシティ)が有ります。perplexityは、確率の逆数です。
正解ラベルが「say」だとして、「say」の予測確率が0.8だったとします。
この時のperplexityは、1/0.8=1.25です。
一方で、ラベルが「say」だとして、「say」の予測確率が0.2だったとします。この時のperplexityは、1/0.2=5です。
つまりperplexityが小さければ小さいほど、予測モデルは良いと言うことになります。上記例は、入力データが一つの時でしたが、実際は入力データは複数有ります。
そんな時は、以下のような式になります。
式は非常に複雑ですが、要は小さければ良いモデルです。笑
もう歌詞生成しよう!笑
なんか説明ばかりでつまらないので、一旦歌詞生成に移りましょう!笑
(後で生成モデルで直接利用してるLSTMも追記しておきます!)
なお実行環境はColaboratoryを想定しています。
# MeCabを利用する準備
!apt install aptitude
!aptitude install mecab libmecab-dev mecab-ipadic-utf8 git make curl xz-utils file -y
!pip install mecab-python3==0.7
# ライブラリのインストール
import torch
import torch.nn as nn
import torch.nn.init as init
import torch.optim as optim
import torch.nn.functional as F
#torchtextを使用
from torchtext import data
from torchtext import vocab
from torchtext import datasets
import requests
from bs4 import BeautifulSoup as bs
import MeCab
import time
import pandas as pd
%matplotlib inline
import numpy as np
from matplotlib import pyplot as plt
# http://j-lyric.net/ から歌詞を取得するための関数を準備
# robots.txtを読んでも大丈夫そうだったので、多分大丈夫
def get_artists_url(top_url):
artist_top = requests.get(top_url)
artist_top = bs(artist_top.content, 'lxml')
urls = artist_top.find_all("p", class_="ttl")
url_list = []
for url in urls:
url_list.append(url.a.get("href"))
return url_list
def get_lyrics(url_list, top_N):
top_lyrics = []
counter = 1
for url in url_list:
url = "http://j-lyric.net/" + url
pre_info = requests.get(url)
info = bs(pre_info.content, 'lxml')
lyric = info.find("p", id="Lyric").text
lyric = lyric.replace("\u3000", "")
top_lyrics.append(lyric)
time.sleep(1)
counter += 1
if counter == top_N:
break
return top_lyrics
# back numberの歌詞を取得
top_url = "http://j-lyric.net/artist/a04fdf2/"
url_list = get_artists_url(top_url)
lyrics = get_lyrics(url_list, 60)
# ちゃんと取れているか確認
lyrics[:5]
# 文字を分かち書きするのに使うので準備
class Sentence(object):
def __init__(self, root):
self.root = root
self.surfaces = []
self.features = []
if self.root:
node = root
while node:
self.surfaces.append(node.surface)
self.features.append(node.feature)
node = node.next
def all_words(self):
for surface, feature in zip(self.surfaces, self.features):
yield surface, feature
def word_count(self):
return len(self.surfaces)
def to_wakati(self):
return ' '.join([w for w in self.surfaces if w])
# そして実際に分かち書きしちゃう
tagger = MeCab.Tagger('-Ochasen')
tagger.parseToNode('')
wakati_lyrics = []
for text in lyrics:
encoded_text = text
node = tagger.parseToNode(encoded_text)
sentence = Sentence(node)
wakati = sentence.to_wakati()
if wakati:
wakati_lyrics.append(wakati + '\n')
# 一旦csvに吐き出す(data.TabularDatasetでcsv読み込みしたいので、csvに吐き出す)
# 果たしてこれが良い方法なのかはわからない
df = pd.DataFrame(wakati_lyrics, columns=["text"])
df.to_csv("lyrics.csv", index=False)
# GPU利用する準備
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# データの読み込み
tokenize = lambda x: x.split()
TEXT = data.Field(sequential=True, tokenize=tokenize, lower=True, batch_first=True)
pos = data.TabularDataset(
path='lyrics.csv',
format='csv',
fields=[('text', TEXT)])
# train_datasetに基づく辞書を作成します
# build_vocabメソッドのvectors引数を使用すると、単語へ番号を振るのと同時に、
# 学習済みの単語ベクトルを指定し、読み込むことができるらしいです。
# 詳しくはこちら → https://qiita.com/itok_msi/items/1f3746f7e89a19dafac5
TEXT.build_vocab(train_dataset, vectors=vocab.GloVe(name='6B', dim=300))
# データセットを分割
train_dataset, val_dataset = pos.split(split_ratio=0.5)
#全単語数
vocab_size = len(TEXT.vocab)
print(vocab_size)
# 単語の件数のtop10
print(TEXT.vocab.freqs.most_common(10))
# 単語
print(TEXT.vocab.itos[:10])
#埋め込みベクトルを取得
word_embeddings = TEXT.vocab.vectors
# ハイパーパラメータ
embedding_length = 300
hidden_size = 256
batch_size = 32
# batchsizeやbptt_lenを指定
# bptt_lenは、文章数が多いと勾配が不安定になるので、文章数を制限する。
train_iter, val_iter = data.BPTTIterator.splits((train_dataset, val_dataset)
, batch_size=32, bptt_len=30, repeat=False)
print(len(train_iter))
print(len(val_iter))
for i, batch in enumerate(train_iter):
print("データの形状確認")
print(batch.text.size())
print(batch.target.size())
print("permuteでバッチを先にする")
print(batch.text.permute(1, 0).size())
print(batch.target.permute(1, 0).size())
print("データ目の形状とデータを確認")
text = batch.text.permute(1, 0)
target = batch.target.permute(1, 0)
print(text[1,:].size())
print(target[1,:].size())
print(text[1,:].tolist())
print(target[1,:].tolist())
print("データの単語列を表示")
print([TEXT.vocab.itos[data] for data in text[1,:].tolist()])
print([TEXT.vocab.itos[data] for data in target[1,:].tolist()])
break
class LstmLangModel(nn.Module):
def __init__(self, batch_size, hidden_size, vocab_size, embedding_length, weights):
super(LstmLangModel, self).__init__()
self.batch_size = batch_size
self.hidden_size = hidden_size
self.vocab_size = vocab_size
self.embed = nn.Embedding(vocab_size, embedding_length)
self.embed.weight.data.copy_(weights)
self.lstm = nn.LSTM(embedding_length, hidden_size, batch_first=True)
# 出力はvocab_sizeに設定しています。
# なぜなら、全単語の内、次に出現するであろう単語がどれかを全単語辞書の中から当てるからです。
self.fc = nn.Linear(hidden_size, vocab_size)
def forward(self, x, h):
x = self.embed(x)
output_seq, (h, c) = self.lstm(x, h)
# 出力を変形する (batch_size*sequence_length, 隠れ層のユニット数hidden_size)
out = output_seq.reshape(output_seq.size(0)*output_seq.size(1), output_seq.size(2))
out = self.fc(out)
return out, (h, c)
# word_embeddingsはTEXT.vocab.vectorsなので、引数weightで学習済みモデル受け取り、
# self.embed.weight.data.copy_(weights)で学習済みのパラメータを再利用するようにしている。
net = LstmLangModel(batch_size, hidden_size, vocab_size, embedding_length, word_embeddings)
net = net.to(device)
とまあコードを流していくと、
「た な 今日 が 同じ 空 の 下 淡々 と 流れ て ゆく もう 価値 もう 価値 もう 価値 もう 価値 ゆく もう 価値 捨て られ た 彼 彼 彼 彼 られ た 彼 彼 彼 られ た 彼 彼 彼 」
みたいなメンヘラっぽい(強引?)テキストが生成されます。
Back Numberの歌詞は、メンヘラっぽい(ファンの方すいません。)ので、今回はいいってことにしましょう。(全然良くない。& もっとそれっぽいのを作れるように頑張ります!)
ちなみに上記コードは「Pytorch ニューラルネットワーク 実装ハンドブック」を参考にしてます。すごくいい本です。Pytorch書くときの辞書として利用できます。
最後に
何か間違っていたら教えて下さい。