先日,シンプルなRecurrent Neural Network(再帰型ニューラルネット)のコードを調べてみたが,これを使って何かやってみたいという発想から,音楽の調,長調/短調を分類できないか,が思い浮かんだ.もちろん音楽に詳しいわけではないので,Wikipedia等で長調,短調について調べることから始めてみた.
調(ちょう,key)は音楽用語の一つ.メロディーや和音が,中心音と関連付けられて構成されているとき,その音楽は調性があるという.狭義には,伝統的な西洋音楽において,全音階(diatonic scale)の音から構成される長調(major key)と短調(minor key)の2つの調が知られ,それぞれ全音階のドの音とラの音が中心音である.
基本定義としてはこのような説明となるが,小中学校で学んだ通り,長調は「明るく元気な感じ」に聞こえるのに対し,短調は「暗く重苦しい感じ」に聞こえる曲調である.これをプログラムで分類できないかについて調べてみた.
長調(major key)と短調(minor key)の説明をもう少し
中心音は根音とも呼ばれるが(以降,base keyと呼ぶ),一番よく知られているのが,"C"をbase keyとする"C major"で,いわゆる「ドレミファソラシド」である.(日本では「ハ長調」とも呼ばれる.) またラの音から始まる短調は,"A minor" (「イ短調」)である.この両者の音階をwikipediaから引用させていただく.
お分かりだろうか? この2つの音階では,ト音記号(オタマジャクシ)の右側にはシャープ記号もフラット記号もつかないプレーンな 楽譜の上にスケールが書かれている.つまり,C majorとA minorは"同じ構成音"から成っている.(要素は同じ.)両者の違いはBase keyが "C" か "A"かというところにある.(A minor が低くシフトしている.)因みに,このような構成音が同じ2つのスケールの関係を「平行調」と呼ぶそうである.
さて音楽のスケールを扱う上で各音に数字を割り振る.
上図は1オクターブ分の鍵盤であるが,一番左の"C"(ド)から順番に 3, 4, 5 ...と整数を割り振る.黒鍵があるところも 番号が割り振られるので,白鍵だけを見るとやや不規則な数列になるが,このやり方でプログラミングを進めることにする.
問題設定とデータセットの生成
長調,短調の代表的なものに"C major"と”A minor”があることは述べたが,これだけでは分類問題としてつまらないので,5つの 長調,5つの短調,計10種類の音楽スケールを扱い,長調(major)と短調(minor)に大別する問題を設定した.
用意したのは,次の10種類のスケール.長調5つと短調5つである.
C major, A minor (これら2つは平行調),
G major, E minor (これら2つは平行調),
D major, B minor (これら2つは平行調),
A major, F sharp minor (これら2つは平行調),
E major, C sharp minor (これら2つは平行調).
これら10種類のスケールからランダムに選び,曲(音のシーケンス)を生成するプログラムを用いた.まず,調のルールに従う定数を用意した.
scale_names = ['Cmj', 'Gmj', 'Dmj', 'Amj', 'Emj', 'Amn', 'Emn', 'Bmn', 'Fsmn', 'Csmn']
cmj_set = [3, 5, 7, 8, 10, 12, 14]
cmj_base = [3, 15]
amn_base = [12, 24]
gmj_set = [3, 5, 7, 9, 10, 12, 14]
gmj_base = [10, 22]
emn_base = [7, 19]
dmj_set = [4, 5, 7, 9, 10, 12, 14]
dmj_base = [5, 17]
bmn_base = [14, 26]
amj_set = [4, 5, 7, 9, 11, 12, 14]
amj_base = [12, 24]
fsmn_base = [9, 21]
emj_set = [4, 6, 7, 9, 11, 12, 14]
emj_base = [7, 19]
csmn_base = [4, 16]
scale_names はスケールの文字列である.次に(1オクターブ分の)7つの整数からなる構成音のリストを用意する.例えば C majorの構成音「ドレミファソラシ」を上図 Key map を参照し,[3, 5, 7, 8, 10, 12, 14]
と設定する.これで1オクターブ分であるので,1オクターブ上の音はこのリスト要素に 12
を足して [15, 17, 19, 20, 22, 24, 26]
と求めることができる.またそれぞれのスケールの Base Keyを定義しておく.C majorの Base Keyを cmj_base = [3, 15]
のように「ド」と1オクターブ上の「ド」とする.
次に短調であるが,前記の通り,C majorとA minorは平行調の関係にあり,構成音は一緒である.A minorのBase Keyだけ amn_base = [12, 24]
(「ラ」の音)と定義する.後で,A minorの曲(シーケンス)を生成する際には,C majorの構成音リスト cmj_set
を参照して行う.このような「平行調」の関係を用いるので,以下のようなスケール定義定数(リスト in リスト)を予め用意して使用した.
scale_db = [
[cmj_set, cmj_base, amn_base],
[gmj_set, gmj_base, emn_base],
[dmj_set, dmj_base, bmn_base],
[amj_set, amj_base, fsmn_base],
[emj_set, emj_base, csmn_base]
]
次にデータセットの生成であるが,疑似コードで表現すると以下のようになる.
# Begin
# 0 .. 9 の乱数を発生させ,対応する 'key' を決める.
key_index = np.random.randint(10)
# 決められた'key'からその構成音1オクターブ分のリストと Base Keyリストを取り出す.
myset, mybase = (scale_db[][], scale_db[][])
# スケールを2オクターブ分に伸長する.
myscale2 = prep_2x_scale(myset)
# シーケンス長さの 'for' ループ
for i in range(m_len):
if i == 0: # 初めの音は,Base Key(1オクターブ上の音)
cur_key = base[1]
else: # 2番目以降の音は,ランダムに方向を決める.
direct = np.random.randint(7)
if t < 3 :
スケールリストで,前の音より一つ低い音を選択する.
if t < 4 :
前と同じ音を選択する.
else:
スケールリストで,前の音より一つ高い音を選択する.
# シーケンスの終わり方チェック
if last_ley in base: # 最後の音が Base Key?
proper = True
データとして採用する.
else
proper = False
終わり方がよくないので,今回のシーケンスは棄てる.
# End
このように乱数を用いて,音のKeyを示す数字のシーケンスを生成している.任意のシーケンス長さ,任意の個数を生成可能であるが,その出力例は次の通りである.
(今回は,シーケンス長さを20としています.)
21, 19, 17, 19, 21, 19, 17, 16, 14, 16, 17, 19, 21, 19, 21, 23, 21, 21, 19, 21, Fsmn
16, 14, 16, 14, 16, 14, 12, 11, 12, 14, 12, 14, 12, 14, 16, 14, 12, 14, 16, 16, Csmn
26, 24, 24, 24, 22, 22, 24, 22, 24, 22, 24, 22, 22, 21, 22, 24, 24, 22, 24, 26, Bmn
21, 23, 21, 19, 21, 23, 24, 24, 26, 26, 24, 23, 21, 19, 21, 19, 21, 23, 23, 21, Fsmn
24, 26, 26, 24, 22, 20, 22, 20, 19, 19, 17, 15, 14, 15, 17, 15, 17, 15, 14, 12, Amn
...
このように整数シーケンスとKeyのラベル(文字列)のセットを出力しているが,1行目のシーケンスでは,'F sharp minor' のベース音 21 で始まり,同じ音 21 で終わっているのが確認できる.また5行目の'A minor' シーケンスでは,ベース音 24 で始まり,その1オクターブ下のベース音 12 で終わっているのが確認できる.
###(補足)
今回設定した問題は,長調(明るい感じ)と短調(暗い感じ)を分類することですが,上のプログラムで生成したデータが妥当なものかどうかは,実際,演奏して聞いてみるのが確かだと思います.私も iPad を取り出してアプリGarageBandで鍵盤を押してみましたが,曲調(明るい/暗い)が分かるように弾くのはなかなか大変で,すぐに断念しました.
(midi規格のフォーマットにして自動演奏すればよかったのかもしれませんが,そこまでやる技量も根性もありません.但し,今回扱った10種類のKey Scaleについては,鍵盤を叩いて(限定的にですが)明るい/暗いを確認しました.)
Neural Networkのモデル(まずMLPで事前検討)
Recurrent Neural Network(RNN)を試す前に,まずMulti-layer Perceptron(MLP) モデルでどうなるかを調べてみた.所定長さの音のシーケンスを,それぞれ独立した数字と考え,これを個数分のネットワークユニットに入力して出力を得るというモデルになる.同じ構成音から,2つの調(長調,短調)を構成できることから,シーケンスを用いないこのモデルでは分類の精度が上がらないと予想していた.
このモデルLayerの構成は,次のようなコードとした.
class HiddenLayer(object):
(略)
class ReadOutLayer(object):
(略)
h_layer1 = HiddenLayer(input=x, n_in=seq_len, n_out=40) # 隠れ層1
h_layer2 = HiddenLayer(input=h_layer1.output(), n_in=40, n_out=40) # 隠れ層2
o_layer = ReadOutLayer(input=h_layer2.output(), n_in=40, n_out=1) # 出力層
隠れ層が2層,最後に出力層の計3層のMLPモデルである.(今回,シーケンス長さはseq_len=20にしている.)このモデルで行った計算の状況が下図となる.
Fig. Loss & Accuracy by MLP model (RMSProp)
赤線がコスト,青線がTrain Dataの分類精度である.ハイパーパラメータの設定(あるいは正則化の処理)が適切でなかったためか振動的な計算となってしまっているが,最終的に精度が 0.65 となっている.2値分類問題なので,サイコロを振る or コイン・トスを行って適当に分類すれば,精度 0.50 なので,このベースラインからは若干改善した精度となった.思っていたほどには悪くなかったという印象である.
計算の初期にLossとAccuracyが停滞している部分と,計算が振動的になっている点が気になったので,オプティマイザを変えて計算してみた結果が下図.
Fig. Loss & Accuracy by MLP model (Gradient Descent)
計算の振動はなくなったが,計算初期の停滞は残っている.また精度は約 0.67 とわずかながら向上している.
RNN (Elman Net)で計算してみたが...
次に本命,シンプルなRNN(Recurrent Nueral Network)であるElman Netを用いて計算を行った.このモデルの主要部は以下のようなコードとした.
class simpleRNN(object):
# members: slen : state length
# w_x : weight of input-->hidden layer
# w_rec : weight of recurrnce
def __init__(self, slen, nx, nrec, ny):
self.len = slen
self.w_h = theano.shared(
np.asarray(np.random.uniform(-.1, .1, (nx)),
dtype=theano.config.floatX)
)
self.w_rec = theano.shared(
np.asarray(np.random.uniform(-.1, .1, (nrec)),
dtype=theano.config.floatX)
)
self.w_o = theano.shared(
np.asarray(np.random.uniform(-1., .1, (ny)),
dtype=theano.config.floatX)
)
self.b_h = theano.shared(
np.asarray(0., dtype=theano.config.floatX)
)
self.b_o = theano.shared(
np.asarray(0., dtype=theano.config.floatX)
)
def state_update(self, x_t, s0):
# this is the network updater for simpleRNN
def inner_fn(xv, s_tm1, wx, wr, wo, bh, bo):
s_t = xv * wx + s_tm1 * wr + bh
y_t = T.nnet.sigmoid(s_t * wo + bo)
return [s_t, y_t]
w_h_vec = self.w_h[0]
w_rec_vec = self.w_rec[0]
w_o = self.w_o[0]
b_h = self.b_h
b_o = self.b_o
[s_t, y_t], updates = theano.scan(fn=inner_fn,
sequences=[x_t],
outputs_info=[s0, None],
non_sequences=[w_h_vec, w_rec_vec, w_o, b_h, b_o]
)
return y_t
(中略)
net = simpleRNN(seq_len, 1, 1, 1)
y_t = net.state_update(x_t, s0)
y_hypo = y_t[-1]
prediction = y_hypo > 0.5
cross_entropy = T.nnet.binary_crossentropy(y_hypo, y_)
説明のために図を参照する.
図は,BPTT法(Backpropagation through time)を前提に,時系列的に展開した構成を示している.音のシーケンスデータは,[X1, X2, X3, ..., Xn] としてこのモデルに入力される.これが重みをかけた後隠れ層Sに出力され,再帰を計算,最後に系列 [Y1, Y2, Y3, ..., Yn] が出力される.このY系列の最後のユニット Yn の出力を活性化関数(Activation Function)を通して2値の数字(0 or 1)を得る.
期待を込めて計算を実行してみたが,残念な結果となった.
Fig. Loss & Accuracy by 1st RNN model (RMSProp)
ほとんど学習が進行せず,最終的な精度は 0.58 でゼロ性能 0.5 と大差なしの結果である.(オプティマイザと変えたり,ハイパーパラメータをいじってみてもだめでした.)
原因としては,出力シーケンスの [Yn] のみを参照し,残りの情報 [Y1 .. Yn-1] を捨てたためではないかと考えた.そこでモデルの改良を検討した.
RNN改良モデル(出力層を追加)
シーケンス [Y1, Y2, ..., Yn] の出力値をすべて参照するため,これらに重みをかけて分類のための信号をつくることにした.
Fig. Simple RNN + Read-out Layer structure
コードとしては,MLPモデルの出力層部分を挿入して作成している.
class simpleRNN(object):
# members: slen : state length
# w_x : weight of input-->hidden layer
# w_rec : weight of recurrnce
def __init__(self, slen, nx, nrec, ny):
(略)
def state_update(self, x_t, s0):
(略)
class ReadOutLayer(object): # <==== 追加クラス
def __init__(self, input, n_in, n_out):
self.input = input
w_o_np = 0.05 * (np.random.standard_normal([n_in,n_out]))
w_o = theano.shared(np.asarray(w_o_np, dtype=theano.config.floatX))
b_o = theano.shared(
np.asarray(np.zeros(n_out, dtype=theano.config.floatX))
)
self.w = w_o
self.b = b_o
self.params = [self.w, self.b]
def output(self):
linarg = T.dot(self.input, self.w) + self.b
self.output = T.nnet.sigmoid(linarg)
return self.output
(略)
net = simpleRNN(seq_len, 1, 1, 1)
y_t = net.state_update(x_t, s0)
y_tt = T.transpose(y_t)
ro_layer = ReadOutLayer(input=y_tt, n_in=seq_len, n_out=1) # <==== 追加
y_hypo = (ro_layer.output()).flatten()
prediction = y_hypo > 0.5
cross_entropy = T.nnet.binary_crossentropy(y_hypo, y_)
(略)
これで計算を実行した状況は,次のようになった.
Fig. Loss & Accuracy by 2nd RNN model (RMSProp)
学習が進行し,最終的な精度も 0.73 と向上した.理由としては,ねらい通り出力シーケンスの情報をうまく取り出せたことや,重み係数(weights) の数が増えて Network の自由度が上がったため,学習過程での適合度(柔軟性)が上がったためと考えている.
しかし精度 0.73 では,当初の期待値以下である.(目標として 0.9 + の分類精度を考えていました.)もう少し各weightsの動きなどを調査して改良すれば,更なる精度upができるかもしれないが,今回はここまでとしたい.
今回は,音楽データをプログラムで乱数で作った人工的なものを使ったが,これも今回の低精度に影響している可能性があると考えている.(実際の音楽にはもっと複雑なルールがあるのでは?)データ等を入手できれば,人間が作ったメロディーに対して長調/短調の分類をやってみたい.(音楽理論についても,もう少し勉強が必要かもしれません.)
参考文献 (web site)
- 調 - Wikipedia
- ハ長調 - Wikipedia
- イ短調 - Wikipedia
- [pdf] Artificial Neural Networks that Classify Music Chords
- Theano scan - Looping in Theano
http://deeplearning.net/software/theano/library/scan.html - Theano optimizers - Gist/ kastnerkyle/opimizers.py
https://gist.github.com/kastnerkyle/816134462577399ee8b2 - 深層学習,講談社機械学習プロフェッショナルシリーズ