LoginSignup
17
4

More than 3 years have passed since last update.

曲のコードをword2vecでベクトル化し、t-SNEで可視化してみた

Last updated at Posted at 2020-11-01

概要

曲はコードと呼ばれる和音によって成り立っています。それらは並び順が非常に大切で、それによって曲の情緒が変わります。複数個のコードの塊をコード進行と読んでいて、例えば【IーVーVImーIIImーIVーIーIVーV】というカノン進行と呼ばれる代表的なものがあります。並び順が大事という点で、曲は文章、コードは単語、と置き換えて考えると、word2vecでベクトル化し、t-SNEで2次元に圧縮して図示すればコード同士の相関が見えるんではないか、という仮定を検証しました。
堅苦しく書きましたが、コード(プログラミング)でコード(和音)を解析するってイカしてね?くらいのノリを共感して頂ければ嬉しいです。

(これは完全に憶測なんですが、リーダブルコードというプログラミングを行う際のコードの書き方をまとめている名著がありまして、そのカバーが音符になっているのはそういうことなのでは、と思っています。。)

【リーダブルコードはこちら】
https://www.oreilly.co.jp/books/9784873115658/

メインで使用する技術

・スクレイピング (selenium==3.141.0)
・Word2Vec (gensim==3.7.3)
・t-SNE (scikit-learn==0.20.3)

前提知識

・pythonの基本的な文法

・コード進行に対する理解(分からなければ、2章は飛ばしてください。)

対象読者

・コード、コード進行を知っている人
・コード進行をローマ数字に置き換えたい人

・python勉強している人
・スクレイピングの方法を知りたい人
・機械学習(自然言語処理)に興味がある人

章構成

3章に分けて書きます。

1章:seleniumを用いたスクレイピングによるデータの収集 (約100行)
2章:コード進行をローマ数字に置き換える作業 (約150行)
3章:word2vecでコードをベクトル化し、t-SNEで図示 (約50行)

seleniumによるスクレイピングを参考にしたい方は1章、音楽に興味がある人は2章、word2vecによってどんな結果が出るのか気になる方は3章をご覧になっていただけたら嬉しいです。

それでは早速、コードの内容に移りましょう。

1章:seleniumを用いたスクレイピングによるデータの収集

今回データを収集する先は、U-FRETさんのサイトに致しました。
数多くの楽曲のデータがあり、精度も高く、歌詞もついているので、弾き語りなどを行う人は一度は使用したことのあるサイトではないでしょうか。

【U-FRETはこちら】
https://www.ufret.jp/

ここでは、アーティストを指定して、その楽曲全てのコード進行と歌詞をcsvに出力するコードを作成していきます。(コードがどっちの意味を指しているかは文脈から判断してください。笑)

pythonでスクレイピングを行うのは、SeleniumかBeautifulSoupが有名だと思いますが、
Seleniumはドライバーを指定して実際に画面遷移を行って要素を取得するのに対し、BeautiflSoupはURLのみ指定して要素を取得します。BeautiflSoupは書きやすく、とっつきやすいかとは思いますが、できることが限られていますので、Seleniumでないと要素が引っ張ってこれないこともあるので、今回はSeleniumを用いました。

scraping.py
from time import sleep
from selenium import webdriver
import chromedriver_binary
import os
import csv
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.select import Select

#アーティスト入力
artist = "aiko"

# 曲名のCSVを出力
fdir = "chord_data/" + artist + "/"
os.makedirs(fdir,exist_ok=True)
# アクセスするURL
TARGET_URL ='https://www.ufret.jp/search.php?key=' + artist


# ブラウザ起動速度上昇
options = webdriver.ChromeOptions()
options.add_argument('--user-agent=hogehoge')
# ブラウザ起動
driver = webdriver.Chrome(options=options)
driver.implicitly_wait(5)
driver.get(TARGET_URL)
url_list= []
urls = driver.find_elements_by_css_selector(".list-group-item.list-group-item-action")
for url in urls:
    text = url.text
    if (not "初心者向け簡単コード" in text) and (not "動画プラス" in text):
        url = url.get_attribute("href")
        if type(url) is str:
            if "song." in url:
                url_list.append(url)

for url in url_list:
    #sleep(3)
    #driver.implicitly_wait(3)
    driver.get(url)
    sleep(5)
    #原曲キーに変更する
    elem = driver.find_element_by_name('keyselect')
    select = Select(elem)
    select.select_by_value('0')
    sleep(1)
    #曲名取得
    title_elem = driver.find_element_by_class_name('show_name')
    title = title_elem.text
    print(title)
    sleep(1)
    #コード取得
    chord_list = []
    chord_elems = driver.find_elements_by_tag_name("rt")
    for chord in chord_elems:
        chord = chord.text
        chord_list.append(chord)

    #歌詞取得
    lyric_list = []
    lyric_elems = driver.find_elements_by_class_name("chord")
    for lyric in lyric_elems:
        lyric = lyric.text.replace('\n',"")
        lyric_list.append(lyric)
    #歌詞のみの箇所を取得
    no_chord_lyric_list = []
    no_chord_lyric_elems = driver.find_elements_by_class_name("no-chord")
    for no_chord_lyric in no_chord_lyric_elems:
        no_chord_lyric = no_chord_lyric.text
        #歌詞リストから歌詞のみを前倒して、コード進行と対応させる
        idx = lyric_list.index(no_chord_lyric)
        lyric_list.remove(no_chord_lyric)
        if idx==0:
            lyric_list[0] = no_chord_lyric + lyric_list[0]
        else:
            lyric_list[idx-1] += no_chord_lyric

    #各歌詞の先頭に記載されるコードを削除し歌詞のみにする
    lyric_list = [lyric.replace(chord_list[idx],"") if chord_list[idx] in lyric else lyric for idx,lyric in enumerate(lyric_list)]

    #スクレイピング結果をcsvに出力する
    fname = fdir + title + ".csv"
    with open(fname, "w", encoding="cp932") as f:
        writer = csv.writer(f)
        writer.writerow([])
        writer.writerow(chord_list)
        writer.writerow([])
        writer.writerow(lyric_list)

ここでは、アーティストはaikoにしました。(最終結果では他のアーティストの解析結果も含めて紹介しております。)

指定の要素がどこにあるかは、macであれば、option+command+iでディベロッパーツールが開きますので、そちらで探してください。

取得自体は対象のclassやtagを設定すればいいのみなので簡単なのですが、歌詞に関してはコード進行と対応させるためにやや複雑な処理になっておりますが、わからなくても問題ありません。

何より大切なことは、U-FRETさんのサーバーに負荷をかけないため&ページロードの処理時間を考慮するために、sleepで時間を置いております。上記の例では、5秒の間隔を開けてサーバーにアクセスしております。ただ、これでも要素取得するのに失敗する場合も時々ありますので、何度か行うか、時間を増やすかして調整しましょう。(implicitly_waitは機能していない気がしました。。お詳しい方いらっしゃいましたら教えていただけると嬉しいです。)

さて、こちらの出力結果は以下のようになります。
スクリーンショット 2020-11-01 16.51.10.png

楽曲名のcsvが出力されます。またコード進行が2行目、それに対応した歌詞が4行目に出力されています。最初の部分はイントロなので、歌詞がついておりません。
次の2章の処理で空白行を埋めるのでここでは気にしないでください。

2章:コード進行をローマ数字に置き換える作業

ここは音楽にある程度精通されている方出ないと理解できないので、わからなければ読み飛ばしてください。機械学習を知っている方であれば、データの前処理として標準化を行っている、というような認識で大丈夫です。簡潔にいうと、絶対的な表示を相対的な表示にして、全てのデータを同じ基準で扱えるようにしていると思ってください。

少し説明をしますと、楽曲には主となる音が存在し、それをkeyと読んでいます。カラオケでも高くてkeyを下げて歌うなんてことを経験した方もいらっしゃると思いますが、そのことです。
例えば、最初に説明をしたカノン進行は【IーVーVImーIIImーIVーIーIVーV】と書きましたが、これでは実際のコードが分からず、演奏することができません。このローマ数字で書かれているコード進行はこの章で行いたい標準化を行った後なのです。実際には、【DーAーBmーF#mーGーDーGーA】というような進行であり、これはkey=Dのカノン進行です。DコードをIとした場合、AコードがⅤで、というように、ローマ数字の方は、鍵盤上での相対的な位置を指しています。

さて、ではどうやってkeyを推定するのかですが、全12種類のkeyのダイアトニックコードを楽曲の全コードのトライアドに照らし合わせ、最も合致率が高かったkeyを調べるというアルゴリズムで判定します。音楽にお詳しいなら言ってる意味がわかりますよね?笑

ということで、煩雑な処理になりますが、コードを見ていきましょう。

scraping.py
import csv
import glob
import collections
import re
import os

artist = "aiko"
#データのディレクトリ
fdir = "chord_data/" + artist + "/"
os.makedirs(fdir,exist_ok=True)
# キーの種類
key_list = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]
# ローマ数字
rome_list = ["Ⅰ","#I","Ⅱ","#Ⅱ","Ⅲ","Ⅳ","#Ⅳ","Ⅴ","#Ⅴ","Ⅵ","#Ⅵ","Ⅶ"]
# ダイアトニックコード
dtnc_chord_list_num = ["Ⅰ","Ⅱm","Ⅲm","Ⅳ","Ⅴ","Ⅵm","Ⅶm7-5"]
# 全全半全全全半
dtnc_step = [2,2,1,2,2,2,1]
#♭を#に変換する
r_dict = {'D♭':'C#', 'E♭':'D#', 'G♭':'F#','A♭':'G#','B♭':'A#'}
#コード進行の読み込み
flist = glob.glob(fdir+"*")

dtnc_chord_list_arr = []

for idx,key in enumerate(key_list):
    pos = idx
    dtnc_chord_list = []
    for num,step in enumerate(dtnc_step):
        #root音 + メジャーorマイナー
        dtnc_chord_list.append(key_list[pos]+dtnc_chord_list_num[num][1:])
        pos += step
        # posを12以下の数値で表す
        if pos >= len(key_list):
            pos -= len(key_list)
    #12種類のキーのダイアトニックコードのリストを格納
    dtnc_chord_list_arr.append(dtnc_chord_list)


for fname in flist:
    with open(fname,encoding='cp932',mode='r+') as f:
        f.readline().rstrip('\n')
        chord_list = f.readline().rstrip('\n')
        f.readline().rstrip('\n')
        lyric_list = f.readline().rstrip('\n')
        #♭を#に変換する
        chord_list = re.sub('({})'.format('|'.join(map(re.escape, r_dict.keys()))), lambda m: r_dict[m.group()], chord_list)
        chord_list = chord_list.split(',')
        chord_list_origin = chord_list.copy()
        # N.C.を取り除く
        chord_list = [chord for chord in chord_list if "N" not in chord]
        #トライアドのみにする
        def get_triad(chord):
            split_chord = list(chord)
            triad = split_chord[0]
            if len(split_chord )>=2 and split_chord[1]=='#':
                triad += split_chord[1]
                if len(split_chord )>=3 and split_chord[2]=='m':
                    triad += split_chord[2]
                    if len(split_chord )>=5 and split_chord[4]=='-':
                        triad += split_chord[3]
                        triad += split_chord[4]
                        triad += split_chord[5]

            elif len(split_chord )>=2 and split_chord[1]=='m':
                triad += split_chord[1]
                if len(split_chord )>=4 and split_chord[3]=='-':
                        triad += split_chord[2]
                        triad += split_chord[3]
                        triad += split_chord[4]
            else:
                pass

            return triad
        # トライアドに変更する
        chord_list_triad = [get_triad(chord) for chord in chord_list]
        length = len(chord_list)
        #コードのユニークカウント
        chord_unique = collections.Counter(chord_list_triad)
        #print(chord_unique)

        #################
        ###keyを決定する###
        #################

        match_cnt_arr = []
        #12種類のkeyでそれぞれマッチ数を計算
        for dtnc_chord_list in dtnc_chord_list_arr:
            match_cnt = 0
            #7個のダイアトニックコードをそれぞれchord_uniqeのkeyの値と照らし合わせ、
            #合致するものがあれば、matchを_cntに加算
            for dtnc_chord in dtnc_chord_list:
                if dtnc_chord in chord_unique.keys():
                    match_cnt += chord_unique[dtnc_chord]
            match_cnt_arr.append(match_cnt)
        #12種類の中で最大のマッチ数
        max_cnt = max(match_cnt_arr)
        #マッチしたコード数/全体のコード数(%)
        match_prb = int((max_cnt/length)*100)

        #キーの決定
        key_pos = match_cnt_arr.index(max_cnt)
        key = key_list[key_pos]
        dtnc_chord_list = dtnc_chord_list_arr[key_pos]
        file_name = os.path.basename(fname).replace('.csv','')
        print('曲名:{0} , key:{1} , prob:{2}'.format(file_name,key,match_prb))
        print(dtnc_chord_list)
        key_list_chromatic = key_list[key_pos:] + key_list[:key_pos]
        # key_list_chromatic.extend(key_list[key_pos:])
        # key_list_chromatic.extend(key_list[:key_pos])
        print(key_list_chromatic)

        #コードをローマ数字に変換し、ファイルに書き込む
        #変換する関数
        def convert_num_chord(chord_list):
            s_list = []
            n_list = []
            for idx, root in enumerate(key_list_chromatic):
                #シャープがついているものを先に置換するため、シャープの有無でrootを分ける
                if '#' in root:
                    s_list.append([idx,root])
                else:
                    n_list.append([idx,root])
            chord_list = ['*' if "N" in chord else chord for chord in chord_list]
            for idx,root in s_list:
                chord_list = [chord.replace(root,rome_list[idx]) if root in chord else chord for chord in chord_list]
            for idx,root in n_list:
                chord_list = [chord.replace(root,rome_list[idx]) if root in chord else chord for chord in chord_list]
            chord_list = ['N.C.' if "*" in chord else chord for chord in chord_list]

            return chord_list

        chord_list_converted = convert_num_chord(chord_list_origin)
        print(chord_list_origin)
        print(chord_list_converted)
    with open(fname, "w", encoding="cp932") as f:
        writer = csv.writer(f)
        writer.writerow('key:{0},prob:{1}'.format(key,match_prb).split(','))
        writer.writerow(chord_list_origin)
        writer.writerow(chord_list_converted)
        writer.writerow(lyric_list.split(','))

いかがだったでしょうか。長くて何をやっているか分かりにくですが、標準化を行った、という認識で構いません。大した処理をしているわけではありませんので、処理は一瞬で終わります。
このコードを実行すると、以下のような結果が得られます。
スクリーンショット 2020-11-01 17.43.45.png

1行目にkey=D#, その所属確率が67%、3行目に標準化後のローマ数字のコード進行が追記されていることが分かります。(aikoはコードがかなり複雑で、ノンダイアトニックコードが多々出てきますので、ダイアトニック所属確率が67%となっております。これでも正しくkeyが推定できているので、この処理の正しさが証明できていると思います。)

それでは最後の章で、この標準化したコードをベクトル化して描画してみましょう。

3章:word2vecでコードをベクトル化し、t-SNEで図示

それでは、aikoの約200曲のデータをword2vecに入れてベクトル化します。次元数は、出力のコードの種類が多くても100程度であることから、10次元に設定しました。また、windowは前後4コードで十分コードの役割が判断できるので、4に指定しました。またアルゴリズムには周辺から中心の単語を推定するCBOW (Continuous Bag-of-Words) を採用するために、sg=0にしました。これによって、各コードが10次元のベクトルに変換されます。
この結果を図として見るために、t-SNEを用いて2次元に圧縮をし、図示しました。ここのperplexity設定は中々難しいところですが、小さい方がクラスタごとに別れやすいため、3に設定しました。

次元を圧縮するくらいだったら、もともとコードの表現次元を2次元にすればいいのでは?と思われる方がいらっしゃると思いますが、そうするとコードの違いがほとんどでず、表現力にかけるため、10次元にしました。

では、コードをみていきましょう。

analyze.py
from gensim.models import Word2Vec
import glob
import itertools
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE

artist = "aiko"
#データのディレクトリ
fdir = "chord_data/" + artist + "/"
#コード進行の読み込み
flist = glob.glob(fdir+"*")
music_list = []
for fname in flist:
    with open(fname,encoding='cp932',mode='r+') as f:
        f.readline().rstrip('\n')
        f.readline().rstrip('\n')
        chord_list = f.readline().rstrip('\n').split(',')
        f.readline().rstrip('\n')
        lyric_list = f.readline()
        chord_list = [chord for chord in chord_list if "N" not in chord]
        music_list.append(chord_list)

#コードをベクトル化
model = Word2Vec(music_list,sg=0,window=4,min_count=0,iter=100,size=10)
chord_unique = list(set(itertools.chain.from_iterable(music_list)))
data = [model[chord] for chord in chord_unique]
print(model.most_similar('Ⅱ'))


#2次元に圧縮して、描画する
tsne = TSNE(n_components=2, random_state = 0, perplexity = 3, n_iter = 1000)
data_tsne = tsne.fit_transform(data)

fig=plt.figure(figsize=(50,25),facecolor='w')

plt.rcParams["font.size"] = 10

for i,chord in enumerate(data_tsne):
    #点プロット
    plt.plot(data_tsne[i][0], data_tsne[i][1], ms=5.0, zorder=2, marker="x",color="red")
    plt.annotate(chord_unique[i],(data_tsne[i][0], data_tsne[i][1]), size=10)
    i += 1

#plt.show()
plt.savefig("chord_data/" + artist + ".jpg")

このコードで得られた結果が以下になります。
aiko.jpg

aikoはコードの種類が多く、クラスターを探すのが難しいですが、
例えば中心から左下にある円には、トライアドであるI、Ⅳ、Ⅴが近くに並んでいます。これは音楽理論的にも正しく、このコードの組み合わせの羅列は最も頻繁に出てくるコードであり、関連性があるためベクトルも近くなったのでしょう。同じ円の中に、Ⅱというコードがありますが、これはノンダイアトニックコードでありながら、近くに存在するのは、おそらくドッペルドミナントとしてⅤのコードの前に存在しやすかったのだと思います。また#Ⅴと#Ⅵが存在しますが、これは#Ⅴ→#Ⅵ→Ⅰという有名な進行での使用が多く、この二つのコードが近くに存在するのだと考えられます。他にも、上の方の円には7thがついたコードの集合があったりと、見ていて面白い結果になったのではないかと思います。

せっかくなので、他のアーティストの結果も載せておきます。
まず、以下はOfficial髭男dismです。
Official髭男dism.jpg

そして、もう一つはandymoriというアーティストです。
こちらは使用しているコードが少ないため、見やすいかもしれません。
andymori.jpg

結論と今後の展望

結論として、コードをword2vecに入れるとそれなり意味のあるデータを導き出してくれる、ということが分かりました。

今後の展望としては、事前に有名なコード進行を覚えさせて、それにとって4個程度のコード進行で楽曲を区切り、
コード進行としてのスパースな行列を作成してLDAでトピックを作成して、アーティスト内での楽曲の類似度を割り出す、なども面白いかもしれません。さらにはアーティスト間でのレコメンドまでできるとなお良いですね。
また、音楽だけ興味のある方向けに、第二章の作業によってどこが転調しているのかがすぐわかるようなツールができるといいなと思っています。

音楽とプログラミングを掛け合わせた記事を書いてる方、ぜひお友達になってください。。

以上、読んでいただきありがとうございました。

17
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
4